3 Predicate Logic
Basically, we is OK, but we better pay attention to detail.
(Wake Up, Essential Logic, 1979)
Propositional logic is simple and clean and can be a lot of fun,
but it’s not expressive enough to make the kind of statements about
mathematics and computer science that we want to make.
Predicate logic adds the quantifiers "for all" (
To strengthen our intuition before going into the formalism, let’s revisit the examples from the Waterloo math course that we used at the beginning of the previous chapter.
The course notes define divisibility as follows:
"We write
We looked at the following theorem:
Divisibility of Integer Combinations (DIC):
Let
We rewrote it in English like this:
"For all
and then in logical notation like this:
Here is the proof of DIC as it appears in the course notes (also quoted in the Introduction chapter):
"Assume that
There are two places where an informal version of
Here we see the simplest, most direct method to prove a
There are also two nearly-identical places
where an informal version of
There is no use of
How might we use or eliminate a for-all statement?
The entire DIC formula is one.
If we wanted to use this result, we would provide instantiations for
Two more things to note. We didn’t have to choose numbers
to instantiate
For the idea of using the quantifiers
In 1902, as Frege was about to publish the second volume of his massive
work Grundgesetze der Arithmetik ("Basic Laws of Mathematics"),
he received a letter from the British philosopher and logician Bertrand Russell,
pointing out a contradiction in his logical system as laid out in the first volume.
In modern terms, Frege’s system allowed the formation of a set from a predicate,
a logical statement with a variable. Given a predicate
Frege did not know how to fix his system. He could not make the contradiction go away without making the system useless for describing mathematics. His final years were not happy ones. Russell’s solution was type theory, elements of which we have already been using. Russell and Alfred North Whitehead used it to publish their own massive three-volume work on the foundations of mathematics, Principia Mathematica (1910-1913). This was very influential work, but the system was overly complicated, and was gradually refined by subsequent researchers. Alonzo Church’s version of simple type theory using the lambda calculus (1940) is now the standard. Among Bertrand Russell’s subsequent achievements were being jailed for pacifism during World War I, and being awarded the Nobel Prize for Literature in 1950. That’s an inadequate summary of his complex and fruitful life, and once again I urge you to do some tangential reading.
It is traditional, in a course like this, to cover classical first-order predicate logic. However, the logical systems described above are not first-order; they are higher-order, as are the systems on which the proof assistants we are aiming for are based. Since Proust is designed to demystify proof assistants, it is also based on a higher-order system. We will not skip first-order logic entirely, as it is extensively used, and is the basis for modern foundations of mathematics. However, since our time is limited, I will just sketch the ideas rather than present them in detail.
It will take a bit of time before I can define "first-order" and "higher-order", and explain why I’m going quickly over material that everyone else does slowly, but the following analogy may provide motivation. In studying functional programming using HtDP or FICS, we start with first-order functions which can only be applied and cannot be used as arguments. We can do a lot with them; in fact, many programming languages, such as C, only have first-order functions. But when we get to lambda and higher-order functions, suddenly our programming language becomes a lot more expressive, and things get a lot more interesting. Something similar happens in logic, though we may not get enough experience in first-order logic for you to see this.
3.1 First-order logic
From propositional logic,
we retain the logical connectives and the idea of variables,
though how we use variables will change.
We add the quantifiers
In mathematics, we typically say things like "For all natural numbers
T | = | ... | ||
| | (∀X T) | |||
| | (∃X T) |
Since variables now represent objects in the domain being quantified over,
a variable by itself is not a logical formula, and we have
have to remove the grammar rule that says so.
We get propositions (to which the logical connectives can be applied)
by adding relational symbols, such as
What relational and functional symbols should we use? It depends on the domain being quantified over, and it’s hard to throw in everything that might be used by mathematicians (who keep making up new symbols to talk about their new areas of study).
For this reason, the language of first-order logic is really a family of languages. Each language in the family has a fixed set of function and relation symbols. Since a function is a special kind of relation, the function symbols are actually redundant, but they certainly help with expressivity. Similarly, we can add named constants (since a constant is just a function with no arguments).
E | = | X | ||
| | (F E ...) | |||
T | = | ... | ||
| | (R E ...) |
As an example, we could talk about arithmetic
by using the functions
With the informal example above, we saw that to use a statement
of the form
Recall that the BHK interpretation gives meanings to each of the logical connectives in terms of what they say about proofs. Putting this together with the discussion above, we arrive at the following interpretation of the for-all quantifier.
"A proof of
In the example in the informal reasoning at the beginning of the chapter,
we saw that to prove a
"A proof of
As we saw with the propositional connectives, the inference rules come in
introduction and elimination flavours.
For each quantifier, there is one introduction rule and one elimination rule.
To create a
Do not take these rules too seriously,
as there are important technical side conditions on them that I am leaving out for now.
To create a
There is a pleasing symmetry to these rules. You should be able to see how the rules correspond to the way quantification is used in the proofs you have seen. Though I have presented these right after a discussion of the BHK intuitionistic interpretation, there is no difference between classical and intuitionistic logic in the treatment of these quantifiers. The difference is on the propositional level, whether LEM or one of its equivalents is considered valid.
The classical semantics for first-order predicate logic extends
the semantics for classical propositional logic.
Recall that the Boolean valuation was the way we defined truth in
classical propositional logic.
We can continue to use this idea
if we figure out how to assign a truth value to the subformulas which
are connected by the logical connectives.
Those subformulas involve
The traditional way to define the classical semantics is
in terms of models.
A model is a domain of quantification
(over which
However, in this particular instance (and typically in general)
this is not the only model possible.
We could make the domain be the Boolean values
It’s not very satisfying to say that the interpretation of something we are trying to make precise is our old informal notion. But there is a more immediate problem: if multiple interpretations are possible, then how can we hope to prove a theorem about the integers if it might also be a theorem about the Boolean values, or some other domain we haven’t thought about? You can probably think of theorems that hold for Boolean values that do not hold for the natural numbers, and vice-versa.
The traditional way to handle this is to use the
Given a model, a valuation is a map from variables to elements of the domain, and this
is extended to give a truth value to formulas in the obvious way.
That is, the value of a function expression
is the interpretation of the function symbol applied to the (recursively defined)
values of the argument expressions. The value of a relation expression is 1 if the relation
holds among the values of the argument expressions; otherwise it is 0. And connectives
work as in the semantics of classical propositional logic.
We write
For classical propositional logic, there are only a finite number of valuations to check
for any sequent, but this is clearly not the case for predicate logic, because of that
"for all models" phrase.
It’s not even clear how one might go about figuring out
what possible models there might be for a given
The proofs of completeness (for there are several ways to do it) are fairly involved,
and are typically covered in senior-level logic courses in mathematics departments.
Fortunately, soundness for both classical and intuitionistic predicate logic
(if
We saw with classical propositional logic that there was an algorithm to decide whether a formula was provable or not: the formula has a fixed number of variables, so try all valuations on those, and if each one makes the formula true, then by completeness, it is provable. The proof of completeness I sketched in the previous chapter also yields an algorithm to construct the proof for a formula.
There is no obvious algorithm for predicate logic (how does one "try all models"?) and indeed one can prove that there is no such algorithm for predicate logic, whether classical or intuitionistic. This was first shown by Alonzo Church in 1936, in the work for which he invented the lambda calculus. Later that year, Alan Turing gave a different proof using a different model of computation, which we now call a Turing machine, for which he showed that the halting problem is undecidable.
Turing’s proof is simple enough that it can be understood by anyone who knows a bit of Racket, since the main requirement is understanding how programs can themselves be manipulated as data. It’s a bit of a tangent, but if you haven’t seen it, I’ve put a writeup of it on this auxiliary page.
Both Church and Turing were primarily motivated by this question of constructive provability,
which had been formulated by David Hilbert, vaguely in 1900 and more precisely in 1924.
In fact, the result holds even for formulas of a special form
using only
The logical system I have just described is called first-order because the quantification is only over objects in the domain. One cannot quantify over sets, or functions, or relations. Yet we do this kind of thing in mathematics all the time, which is why Frege and Russell used higher-order logical systems.
There are some interesting limitations on the expressiveness of first-order predicate logic. One cannot throw in enough rules to ensure that the language of arithmetic only admits the usual model of the integers. One can add enough to rule out, for example, the model of Boolean values, but there will always be models that look similar to the natural numbers we know and love but are not quite the same. One cannot express the concept that the domain is finite, or countable. So, for example, there is an uncountable model (has the same cardinality as the real numbers) in which exactly the logical statements that hold for the integers hold in that model. This is both fascinating and somewhat disturbing.
So why did first-order logic become the standard? There is no simple reason for this, just as there is no simple reason that Java and C++ dominate computing. The metamathematics of first-order logic is quite interesting, especially the rich field known as model theory. First-order logic has proved sufficient to define set theory, and thus the foundations of mathematics. There is a paper linked on the Resources page which provides much insight into the history of logic, if you wish to learn more.
For computer scientists, there are additional frustrations. We’ve seen how our proof terms for propositional logic provide useful computational tools like lambda and pairs, even though we haven’t yet used them for actual computation. In first-order logic, we don’t directly construct useful datatypes. Instead, we add rules that describe the properties of these datatypes and draw conclusions from them. In contrast, the development below will continue the introduction/elimination approach, with familiar constructors for datatypes. Furthermore, propositions will also become objects on which computations can be performed. All of this improves expressivity relative to first-order logic.
If we wanted to spend time on first-order predicate logic, we could define proof terms for it, extending what we did for propositional logic. But there are many technical details, some of which are glossed over in informal presentations, but which implementations cannot neglect. Much of this work would have to be replicated in a slightly altered fashion when moving to higher-order logic.
Instead, let’s jump ahead to higher-order logic, and bring Proust along with us.
3.2 Higher-order logic
We hope to be able to prove theorems about user-defined computations,
as we did in the Introduction with append.
It follows that things we have been keeping on the left-hand (term) side of
the
expr | = | (λ x => expr) | ||
| | (expr expr) | |||
| | (expr : expr) | |||
| | x | |||
| | (expr -> expr) |
This grammar by itself lets us put expressions on the right-hand side of a colon that are not valid types, such as ((λ x => x) : (λ y => y)). Previously the parse-type function would report an error if given (λ y => y), but we will also merge the parse functions for terms and types, as well as the pretty-print functions. We move the responsibility for reporting invalid types into type-synth. If we let Type be the type of valid types, we have the following rules derived from our previous grammar for types. Since position will no longer differentiate a variable from a base type, we use the context to disambiguate. (This is an awkward choice, and it’s only temporary.)
If
(struct Type () #:transparent)
Here are the new type checking and synthesis functions.
(define (type-check ctx expr type) |
(match expr |
[(Lam x t) |
(type-check ctx type (Type)) |
(match type |
[(Arrow tt tw) (type-check (cons `(,x ,tt) ctx) t tw)] |
[else (cannot-check ctx expr type)])] |
[else (if (equal? (type-synth ctx expr) type) true (cannot-check ctx expr type))])) |
(define (type-synth ctx expr) |
(match expr |
[(Lam _ _) (cannot-synth ctx expr)] |
[(Ann e t) (type-check ctx e t) t] |
[(App f a) |
(define tf (type-synth ctx f)) |
(match tf |
[(Arrow tt tw) #:when (type-check ctx a tt) tw] |
[else (cannot-synth ctx expr)])] |
[(Arrow tt tw) |
(type-check ctx tt (Type)) |
(type-check ctx tw (Type)) |
(Type)] |
[(? symbol? x) |
(cond |
[(assoc x ctx) => second] |
[else (Type)])])) |
If we were going no further, this would be an inferior design, as it mixes together things that can and should be kept separate. But they cannot be kept separate in what we are about to do.
The code is linked in the Handouts chapter as proust-pred-start.rkt. You should look at it to make sure that all the merged functions make sense to you.
We have a different implementation of something we could do before (propositional logic with only implication) but nothing new yet. In the next subsection, we will start in on higher-order predicate logic, by starting to work on adding for-all. This is where we seriously depart from a conventional treatment, but the result will be a small language with considerable power, even without the rest of the logical connectives.
3.2.1 Rules for universal quantification
We want to be able to quantify over more than a single, unspecified domain.
But we can’t quantify over just anything; we run the risk of introducing
Russell’s paradox.
The solution we adopt is based on Russell’s solution:
we quantify over things that are typeable.
Instead of
We now slightly change the BHK interpretation to take the new idea into account, with the added clarification in bold below:
"A proof of
of type
We will be focussing on
This suggests that the proof term for creating a for-all formula should act like the proof term for creating an implication, that is, it should be some sort of generalized lambda. But what kind of generalization? Let’s look at the BHK interpretation again, rephrased to make the Curry-Howard connection more explicit:
"A proof of
The only change to our earlier notion of lambda needed here is that
the type of the result can depend on the value of the argument.
This is known as a dependent type.
It is not common in programming languages, because of the complexity
it brings to the type system, but it is just the right idea for a proof assistant.
(Haskell will have optional dependent types in an upcoming release.)
The idea is useful even when
The for-all introduction rule resembles the implication introduction rule (which it replaces, being more general) but because of the mingling of terms and types, we do have to check for well-formed types.
In general, the type
Fortunately, we already represented sets as lists in Proust,
so they are already sequences.
But the previous side condition
How do we use or eliminate a for-all? Is it like using or eliminating an implication?
Again, yes. Remember our discussion of
The proof term for
It’s convenient to be able to collapse adjacent for-alls together in human-readable notation,
as in
We’ve introduced quantification over a type, but we can also consider quantification over all types.
Previously, we typed the identity function,
Since
But
For the sake of simplicity, we are going to say that
The form of
Another deviation from rigourous correctness I will make at this point is that I will stop writing out all the inference rules. Some of them will be inherent in the code, which can be easier to understand than an equivalent set of rules. I’ve already been doing this in a sneaky fashion; lookup judgments, even in our most basic propositional system, require formal rules, but I have simply used Racket’s association list lookup function. This means you have to pay even more attention to the code. Don’t just trust me or past users; use your own critical facilities.
Challenge: keep track of all changes and additions to the inference rules. Some of the references in the Resources chapter can help with verifying your work.
Adding
Besides
We don’t have the other logical connectives yet, but their rules in predicate logic are straightforward adaptations of their rules in propositional logic. In this manner, predicate logic subsumes all of propositional logic, and our implementation can be extended in a similar fashion.
To facilitate the discussion, let’s postulate an additional type constant
We can show that
That is our intuition, but is it justified? Recall the BHK interpretation of for-all:
"A proof of
Looking at the effect of applying such a function to such an
To summarize, we’ve just seen a need for computation to take place during typechecking (though we’re still vague on exactly where that happens). We want to introduce computation anyway, because we want to prove theorems about programs, but even if we didn’t have this ambition, we’d still have to talk about it, because we are using dependent types. The computation that takes place during typechecking and the computations we will define in order to prove theorems about them will use the same model, which is based on substitution, just as with our mental model of Racket function application.
A conventional treatment of first-order logic doesn’t have to talk about computation, but it does have to discuss substitution. Some treatments try to get away with just the intuitive idea of substitution, but this can lead to problems even with on-paper proofs. Furthermore, to add computation to first-order logic, the system must be expanded with rules for the computational model, whereas here we’ve already done the work.
It all hinges on substitution, and the time has come to be precise about that.
3.2.2 Substitution and computation
The issues with substitution are the same for
We did not give a computational meaning to proof terms when discussing propositional logic.
They were just concise representations of proof trees.
But the computational meaning is familiar to
you from learning Racket, even if the syntax is different.
You know that
You might object to
Here, we are taking off binders when forming the premise of a use of the for-all introduction rule, and later we’ll encounter situations in which we have to substitute under binders also. So we can’t avoid dealing with free variables. This is one source of the technical side conditions I left out when describing the inference rules for first-order logic. It’s unavoidable even in a conventional presentation done correctly.
Let’s try to define substitution for lambdas in a way that respects our intuition above. The definition will be recursive in the structure of expressions, so we can implement it using structural recursion. What should happen when substituting into a variable? There are two cases, depending on whether or not the variable being substituted into is equal to the variable being substituted for.
Substitution into an application just substitutes into the two parts.
We can’t do the same for lambda, because again there are two cases.
If we are substituting for
Even this does not handle all cases involving free variables.
Consider
What we need to do is first rewrite
In the example, we just changed the
We can’t choose a
There is one more condition on the choice of
Here are all the rules summarized.
If you have not seen this definition before, you are probably glad to have been spared it until now. You may be asking yourself, how do we know we have gotten it right, that we haven’t missed some other case or other side condition? One answer is that we can formally prove that this definition has the properties we want of substitution, and that theorems about the ways we use substitution (for example, in computation) can also be proved formally. Some of the references in the Resources chapter do this.
3.2.3 Implementing computation
We don’t have a use for computation in propositional logic,
but let’s add it to proust-pred-start.rkt,
which currently only handles propositional logic.
To avoid confusion, we’ll strip out all the types (hence all the logic!)
and call it proust-just-comp.rkt.
This may seem alarming, but types play no role in computation.
Types can tell us something about the result of computation,
since typically we define types so that if an expression has type
In order to implement substitution, we need ways to check if a variable is free in a term and if a variable is a binder in a term. The simplest way to do this is to just descend through the term recursively, looking for places it might be free or a binder, respectively. Since these searches are very similar, we combine them into a single function.
The definition of substitution in the previous subsection,
in mathematical form, says that the variable
(define (subst oldx newx expr) |
(match expr |
[(? symbol? x) (if (equal? x oldx) newx x)] |
[(Lam x b) |
(cond |
[(equal? x oldx) expr] |
[(side-cond? x (list newx) false) |
(define repx (refresh x (list newx b))) |
(Lam repx (subst oldx newx (subst x repx b)))] |
[else (Lam x (subst oldx newx b))])] |
[(App f a) (App (subst oldx newx f) (subst oldx newx a))] |
[(Ann e t) (Ann (subst oldx newx e) (subst oldx newx t))])) |
(define (refresh x lst) |
(if (side-cond? x lst true) (refresh (freshen x) lst) x)) |
(define (freshen x) (string->symbol (string-append (symbol->string x) "_"))) |
(define (side-cond? x lst check-binders?) |
(ormap (lambda (expr) (sc-helper x expr check-binders?)) lst)) |
(define (sc-helper x expr check-binders?) |
(match expr |
[(? symbol? y) (equal? x y)] |
[(Lam y b) |
(cond |
[check-binders? (or (equal? x y) (sc-helper x b check-binders?))] |
[else (if (equal? x y) false (sc-helper x b check-binders?))])] |
[(App f a) (side-cond? x (list f a) check-binders?)] |
[(Ann e t) (side-cond? x (list e t) check-binders?)])) |
Because some of the side conditions require looking at more than one expression, the helper function refresh consumes a variable x and a list of expressions. The side condition helper function sc-helper has as its last parameter a Boolean value specifying whether or not to check if x occurs as a binder in an expression.
Challenge: Racket has a function called gensym which creates a symbol guaranteed not be in use. Both this and our simpler option change the name, and we’d want to change it back in the pretty-printing. Racket also has support for sets as a datatype, allowing us to calculate the set of free variables in a term only once. Use these ideas to improve the efficiency and usability of substitution.
We could define computation using inference rules,
and that is what we would do if we wanted a mathematically precise definition.
If an expression is a lambda applied to an argument subexpression,
then one step of computation consists of substituting the argument
for the variable of the lambda, in the body of the lambda.
But we also need rules to allow subexpressions to step.
For example, if
That doesn’t give us much guidance on implementation, and it’s easy to construct an inefficient algorithm. We fared better with bidirectional typing because it used structural recursion, but computation doesn’t. Thinking directly about a Racket implementation will be more fruitful.
If we want to reduce an expression, there are three cases. A variable cannot be reduced further. To reduce a lambda, reduce its body. To reduce an application, reduce the expressions in function position and argument position. If the expression in function position reduces to a lambda, do the substitution, then reduce the result (this is not structural recursion). The Racket code is a direct translation of this paragraph.
(define (reduce expr) |
(match expr |
[(? symbol? x) x] |
[(Lam x b) (Lam x (reduce b))] |
[(App f a) |
(define fr (reduce f)) |
(define fa (reduce a)) |
(match fr |
[(Lam x b) (reduce (subst x fa b))] |
[else (App fr fa)])])) |
The code reduces in the bodies of lambdas, even though I said above that this is not done in most programming languages. I’ll discuss the reasons for this below. The full code is linked in the Handouts chapter as proust-just-comp.rkt.
3.2.4 Back to predicate logic
When we move back to our previous setting, namely trying to add rules for for-all to our minimal propositional system with only implication, we have to add to some of the code above. Here is the grammar so far.
expr | = | (λ x => expr) | ||
| | (expr expr) | |||
| | (expr : expr) | |||
| | x | |||
| | (∀ (x : expr) -> expr) | |||
| | (expr -> expr) | |||
| | Type |
To replace the dot in our human-readable for-all notation, I have chosen single right-arrow (made with a hyphen and greater-than characters) because for-all generalizes implication (also retained for convenience and readability). Previously, we extended this grammar to allow the removal of parentheses on multiple applications and multiple arrows. We can do that, and in addition, allow multiple arguments in for-alls and lambdas.
Since implication is now redundant, we can remove it as an AST node type,
but leave it in the user term language, to be
desugared by the parser (as we did with negation)
into an instance of
The Arrow data structure, being now used for for-all, is now more general, so we need to put more information (the name of the variable) into the AST node.
(struct Arrow (var domain range) #:transparent)
We need to change the parsing and pretty-printing functions (not further described here; the full code is linked below).
Extending substitution to the syntax not present in proust-pred-start.rkt is not difficult. The code for for-all resembles the code for lambda; the code for annotation and the function type resemble the code for function application.
(define (subst oldx newx expr) |
(match expr |
[(? symbol? x) (if (equal? x oldx) newx x)] |
[(Arrow '_ t w) (Arrow '_ (subst oldx newx t) (subst oldx newx w))] |
[(Arrow x t w) |
(cond |
[(equal? x oldx) expr] |
[(side-cond? x (list newx) false) |
(define repx (refresh x (list newx w))) |
(Arrow repx (subst oldx newx t) (subst oldx newx (subst x repx w)))] |
[else (Arrow x (subst oldx newx t) (subst oldx newx w))])] |
[(Lam '_ b) (Lam '_ (subst oldx newx b))] |
[(Lam x b) |
(cond |
[(equal? x oldx) expr] |
[(side-cond? x (list newx) false) |
(define repx (refresh x (list newx b))) |
(Lam repx (subst oldx newx (subst x repx b)))] |
[else (Lam x (subst oldx newx b))])] |
[(App f a) (App (subst oldx newx f) (subst oldx newx a))] |
[(Ann e t) (Ann (subst oldx newx e) (subst oldx newx t))] |
[(Type) (Type)])) |
Since the sc-helper function that does most of the work in checking side conditions works by structural recursion on the AST, it also has to handle all the possibilities.
(define (sc-helper x expr check-binders?) |
(match expr |
[(? symbol? y) (equal? x y)] |
[(Arrow '_ tt tw) (side-cond? x (list tt tw) check-binders?)] |
[(Arrow y tt tw) |
(cond |
[check-binders? (or (equal? x y) (side-cond? x (list tt tw) check-binders?))] |
[else (if (equal? x y) false (side-cond? x (list tt tw) check-binders?))])] |
[(Lam '_ b) (sc-helper x b check-binders?)] |
[(Lam y b) |
(cond |
[check-binders? (or (equal? x y) (sc-helper x b check-binders?))] |
[else (if (equal? x y) false (sc-helper x b check-binders?))])] |
[(App f a) (side-cond? x (list f a) check-binders?)] |
[(Ann e t) (side-cond? x (list e t) check-binders?)] |
[(Type) false])) |
For reduction, all the added code resembles the code for function application, except for the annotation case, where the type annotation is simply dropped.
(define (reduce ctx expr) |
(match expr |
[(? symbol? x) |
(cond |
[(assoc x ctx) x] |
[else x])] |
[(Arrow '_ a b) |
(Arrow '_ (reduce ctx a) (reduce ctx b))] |
[(Arrow x a b) |
(define ra (reduce ctx a)) |
(define rb (reduce (cons (list x ra) ctx) b)) |
(Arrow x ra rb)] |
[(Lam '_ b) (Lam '_ (reduce ctx b))] |
[(Lam x b) (Lam x (reduce (cons (list x '()) ctx) b))] |
[(App f a) |
(define fr (reduce ctx f)) |
(define fa (reduce ctx a)) |
(match fr |
[(Lam x b) (reduce ctx (subst x fa b))] |
[else (App fr fa)])] |
[(Ann e t) (reduce ctx e)] |
[(Type) (Type)])) |
We still have to nail down the role of reduction in typechecking. But there are some lessons to be learned from substitution. We sometimes have to change the name of a variable introduced by a binder to avoid variable capture. As a result of these rewritings, we may compute a lambda or for-all that doesn’t look exactly like we expect, but is the same up to the choices of variable names. We should regard these as the same. This is known as alpha-equivalence.
We need to use this notion when we check types for equality, notably in the "turn" rule, where to check a type, we synthesize it and then compare to what we thought it should be. We used structural equality before, but we really should use alpha-equivalence.
A typical treatment of these issues usually mentions alpha-equivalence, but uses it to gloss over difficulties in substitution, assuming a human reader knows how to tweak things to avoid difficulty. (This is sometimes known as the Barendregt convention, after an author who has published extensively on the lambda calculus.)
But in implementing these ideas on a computer, we have to get all the details straight. To the best of my knowledge, no mathematician before Church (1936) really took these matters seriously. Much of Curry’s work deals with a setting where lambda is absent, replaced by specific combinators (which consume and produce functions). For example, we can have a combinator which composes two functions. This avoids the complications of naming (and definitely simplifies metamathematical proofs), at the cost of making "programming" more difficult.
Another solution to this problem was pioneered by de Bruijn, who replaced names by
a number indicating lexical depth.
We need to write code to decide the alpha-equivalence of two expressions.
Having defined substitution and variable-refreshing,
we could use simultaneous almost-structural recursion on the two expressions,
similar to checking for structural equality.
When we reach, say, a
But there is a simpler and more direct approach.
Instead of renaming with a fresh
(define (alpha-equiv? e1 e2) (ae-helper e1 e2 empty)) |
(define (ae-helper e1 e2 vmap) |
(match (list e1 e2) |
[(list (? symbol? x1) (? symbol? x2)) |
(define xm1 (assoc x1 vmap)) |
(equal? (if xm1 (second xm1) x1) x2)] |
[(list (Lam x1 b1) (Lam x2 b2)) (ae-helper b1 b2 (cons (list x1 x2) vmap))] |
[(list (App f1 a1) (App f2 a2)) (and (ae-helper f1 f2 vmap) |
(ae-helper a1 a2 vmap))] |
[(list (Ann e1 t1) (Ann e2 t2)) (and (ae-helper e1 e2 vmap) |
(ae-helper t1 t2 vmap))] |
[(list (Arrow x1 t1 w1) (Arrow x2 t2 w2)) |
(and (ae-helper t1 t2 (cons (list x1 x2) vmap)) |
(ae-helper w1 w2 (cons (list x1 x2) vmap)))] |
[(list (Type) (Type)) true] |
[else false])) |
(define (equiv? e1 e2) (alpha-equiv? e1 e2)) |
That last definition is there because we are about to use an even more expansive notion of equivalence, and we will change the definition of equiv?.
Racket’s and form is not a function, but a macro, because it does short-cutting: if the first argument evaluates to false, it will not evaluate the second argument. So this implementation of alpha-equivalence might stop early, if it detects a mismatch. As stated earlier, here we prioritize readability above efficiency, but such considerations are important in systems intended for real use.
Alpha-equivalence is analogous to modular arithmetic,
where instead of being restricted to statements like
(define (equiv? e1 e2) (alpha-equiv? (reduce e1) (reduce e2)))
This definition of type equivalence, called definitional equality, has proved very useful in practice, but it is not the only choice that can be made at this point.
Once again, there are opportunities for improving efficiency. Fully reduced expressions can be quite large, and it might be better to reduce on the fly, since the equivalence check might stop early.
The one place we are using type equivalence so far is the turn rule. But there may be other places where reduction might be needed. Here is the rule for for-all elimination again.
In our code so far, we synthesize the type of
Here is another example of the same issue, in the rule for for-all introduction (leaving off the side conditions).
The code synthesizes the type of
We will use full reduction in our implementation, but more efficient methods are available here also. We could reduce just far enough to see that the top constructor is for an arrow type, and not fully reduce its subexpressions. Stated in more generality, this is known as reduction to weak head normal form (WHNF), and it is an important technique in programming languages. Being aware of weak reduction can help us to understand what full-featured proof assistants like Agda or Coq are doing in certain situations.
We are almost ready to write the full code for type checking and synthesis.
There were three issues we put off until later, and the time has arrived to deal with them.
One was that whenever we add a variable
(define (used-in-ctx? ctx x) |
(or (assoc x deftypes) |
(ormap (λ (p) (side-cond? x (rest p) false)) ctx))) |
(define (refresh-with-ctx ctx x lst) |
(if (or (side-cond? x lst true) (used-in-ctx? ctx x)) |
(refresh-with-ctx ctx (freshen x) lst) |
x)) |
The second issue had to do with the new rule for checking the type of a lambda,
which you can see just above.
We’d like to relax the condition that
If instead the type is
The third issue is the use of underscores, which requires some special cases. If one of the term or type has an underscore as variable and the other does not, then we try to use the actual variable to replace the underscore (checking the requisite conditions), and if we can’t, we freshen and substitute into both, as above.
Here’s the code. Parts of it look long, but they are mostly just variations on the same basic idea, trying to minimizing the amount of rewriting done.
(define (type-check ctx expr type) |
(match expr |
[(Lam x b) |
(type-check ctx type (Type)) |
(define tyr (reduce type)) |
(match tyr |
[(Arrow x1 tt tw) |
(match (list x x1) |
[(list '_ '_) (type-check ctx b tw)] |
[(list '_ x1) |
(cond |
[(nor (side-cond? x1 (list b) false) (used-in-ctx? ctx x1)) |
(type-check (cons (list x1 tt) ctx) b tw)] |
[else |
(define newx (refresh-with-ctx ctx x1 (list b type))) |
(type-check (cons (list newx tt) ctx) b (subst x1 newx tw))])] |
[(list x '_) |
(cond |
[(nor (side-cond? x (list type) false) (used-in-ctx? ctx x)) |
(type-check (cons (list x tt) ctx) b tw)] |
[else |
(define newx (refresh-with-ctx ctx x (list b type))) |
(type-check (cons (list newx tt) ctx) (subst x newx b) tw)])] |
[(list x x1) |
(cond |
[(and (equal? x x1) (not (used-in-ctx? ctx x1))) |
(type-check (cons (list x tt) ctx) b tw)] |
[(nor (side-cond? x (list type) true) (used-in-ctx? ctx x)) |
(type-check (cons (list x tt) ctx) b (subst x1 x tw))] |
[else |
(define newx (refresh-with-ctx ctx x (list expr type))) |
(type-check (cons (list newx tt) ctx) (subst x newx b) (subst x1 newx tw))])])] |
[else (cannot-check ctx expr type)])] |
[else (if (equiv? ctx (type-synth ctx expr) type) true (cannot-check ctx expr type))])) |
(define (type-synth ctx expr) |
(match expr |
[(? symbol? x) |
(cond |
[(assoc x ctx) => second] |
[else (cannot-synth ctx expr)])] |
[(Lam x b) (cannot-synth ctx expr)] |
[(App f a) |
(define t1 (reduce (type-synth ctx f))) |
(match t1 |
[(Arrow '_ tt tw) #:when (type-check ctx a tt) tw] |
[(Arrow x tt tw) #:when (type-check ctx a tt) (subst x a tw)] |
[else (cannot-synth ctx expr)])] |
[(Ann e t) (type-check ctx t (Type)) (type-check ctx e t) t] |
[(Arrow '_ tt tw) |
(type-check ctx tt (Type)) |
(type-check ctx tw (Type)) |
(Type)] |
[(Arrow x tt tw) |
(type-check ctx tt (Type)) |
(type-check (cons `(,x ,tt) ctx) tw (Type)) |
(Type)] |
[(Type) (Type)])) |
This code is not practically usable without definitions. Testing it would require building up successively larger expressions using cut and paste.
3.2.5 Adding definitions
When developing Proust for propositional logic, we used holes, which were very convenient for interactive development. They are also convenient for predicate logic, as we will see when looking at Agda and Coq, but it is considerably more difficult to add them. I will defer a principled treatment of holes in the context of dependent types to a later, optional chapter.
We could put in holes just as we did for propositional logic, and let them typecheck as anything. But we cannot do refinement in the same way. It is no longer the case that we can fill in one hole with anything that has the type we associated with the hole during typechecking. In the presence of dependent types, there can be also be dependencies between holes. Handling these dependencies can be complicated, and neglecting them can lead the user into blind alleys.
Global definitions can serve as a partial substitute, so at least we can build up terms incrementally. They also reduce the size of terms by shrinking repeated subexpressions.
We’ll implement definitions with two global association lists, one for definitions (mapping names to terms) and one for the types of defined terms (mapping names to types). There are more efficient ways to do this, but once again this is easy to code and understand. You will miss holes when you do the exercises, but they return with even more sophistication with Agda and Coq, which we will get to soon.
Refactored Proust for propositional logic interpreted a symbol not in the current context as a propositional variable. But in Proust for predicate logic, the only variables are the ones introduced by binding forms. So if a symbol is not in the context or global definitions, it has no meaning, and an error must be raised.
(define defs empty) |
(define deftypes empty) |
; def : symbol Expr -> void |
; side effect is to mutate global defs, deftypes to include new def if it checks out |
(define (def name expr) |
(when (assoc name defs) (error 'def "~a already defined" name)) |
(define e (parse-expr expr)) |
(define et (type-synth empty e)) |
(match e |
[(Ann ex tp) (set! defs (cons (list name ex) defs)) |
(set! deftypes (cons (list name tp) deftypes))] |
[else (set! defs (cons (list name e) defs)) |
(set! deftypes (cons (list name et) deftypes))])) |
(define (clear-defs) (set! defs empty) (set! deftypes empty)) |
We need to add a line in type-synth looking up the type of a variable that might be a global definition (after first looking for it in the context).
(define (type-synth ctx expr) |
(match expr |
[(? symbol? x) |
(cond |
[(assoc x ctx) => second] |
[(assoc x deftypes) => second] ; added line |
[else (cannot-synth ctx expr)])] |
...)) |
Fully reducing an expression means expanding definitions, so we need something similar there. But reduce does not have the context as an argument, and that must be added.
(define (reduce ctx expr) |
(match expr |
[(? symbol? x) |
(cond |
[(assoc x ctx) x] |
[(assoc x defs) => (lambda (p) (reduce ctx (second p)))] |
[else x])] |
...)) |
The full code is available in proust-pred-forall.rkt, with the beginnings of a test suite that also shows how one might use Proust to prove theorems in predicate logic.
For propositional logic, there was a clear correspondence between the tree structure of a proof built by inference rules and the tree structure of the concise proof term we used. That correspondence no longer holds for our proof terms for predicate logic. The computational steps (reductions) are not explicitly represented in the proof terms. They are verified when the proof term is typechecked.
The omission of the computations in our proof terms makes them more concise than proof trees. Informal proofs are also more concise, with the brain of the expert reader filling in what’s left out. But it’s not as easy to get a machine to check an informal proof. Here the omissions correspond nicely to what a machine can do, and furthermore, the computations take place in a simple computational model that we understand (one very similar to what we have been using to construct our proof assistant), so the proof terms remain comprehensible to us.
3.2.6 Simulating other features
This subsection and the next one are optional reading.
Alonzo Church, in his 1936 work, argued that the lambda calculus was a general model of computation by showing how it could simulate Booleans, pairs, and arithmetic. That was in an untyped setting. That implementation cannot be tweaked to typecheck in the simply-typed lambda calculus, or in the version of the lambda calculus corresponding to first-order quantification. John Reynolds showed how to extend the simply-typed lambda calculus with quantification over types so that Church’s implementation would typecheck. This can be done in the higher-order language we are now working with.
As you can imagine, this involves heavy use of lambda, and this
provides further justification for our decision to
reduce applications of lambda anywhere in the expression.
Otherwise, in the simulation of arithmetic, the simulation of
You are familiar with Booleans from prior programming experience, as well as our discussion of the Boolean interpretation. The general idea of Church’s encodings is to encode values by their eliminators. How do we eliminate or use a Boolean value? With an if-then-else. That is, a Boolean value b will be encoded by the function that consumes two things and produces the first if b is true and the second if it is false.
This leads to the following definitions in the untyped lambda calculus:
We can use polymorphism to define typed versions of these in Proust.
First of all, we need a type that works for both
(def 'bool '((∀ (X : Type) -> (X -> X -> X)) : Type))
That might seem a bit generous, but it is possible to show
that versions of
(def 'true '((λ x => (λ y => (λ z => y))) : bool)) |
(def 'false '((λ x => (λ y => (λ z => z))) : bool)) |
And we can now easily type a version of
(def 'if '((λ X => (λ b => (λ t => (λ f => (b X t f))))) |
: (∀ (X : Type) -> (bool -> (X -> (X -> X)))))) |
This, in turn, can be used to code Boolean functions.
(def 'band '((λ x => (λ y => (x bool y false))) |
: (bool -> (bool -> bool)))) |
The same ideas can be made to work for the logical connectives, pairs, and natural numbers. I’ll sketch the ideas, and you can work out the details as a challenge.
Logical AND (
(def 'and '(? : (Type -> (Type -> Type))))
Using the general idea that an encoding of a datatype is the type of an eliminator, we would like the replacement for ? to be the type of an AND eliminator. But there are two eliminators for AND. So we use polymorphism to generalize the type to encompass both.
(def 'and '((λ p => (λ q => (∀ (c : Type) -> ((p -> (q -> c)) -> c)))) : (Type -> (Type -> Type))))
Now you can finish the definitions of
conj (which is
(def 'conj '(? : (∀ (p : Type) -> (∀ (q : Type) -> (p -> (q -> ((and p) q))))))) (def 'proj1 '(? : (∀ (p : Type) -> (∀ (q : Type) -> (((and p) q) -> p))))) (def 'proj2 '(? : (∀ (p : Type) -> (∀ (q : Type) -> (((and p) q) -> q))))) (def 'and-commutes '(? : (∀ (p : Type) -> (∀ (q : Type) -> (((and p) q) -> ((and q) p))))))
Next, you will tackle natural numbers.
How might we use or eliminate a natural number?
By iterating.
A general form of iteration using a natural number
(def 'nat '((∀ (x : Type) -> ((x -> x) -> x -> x)) : Type)) (def 'z '(? : nat)) (def 's '(? : (nat -> nat))) (def 'one '((s z) : nat)) (def 'two '((s (s z)) : nat)) (def 'plus '(? : (nat -> (nat -> nat))))
You should be able to prove that, for example, ((plus one) one) equals two, though since we have not implemented equality in Proust’s term language yet, all you can do is show that these two are equal using equiv?. But you will not be able to prove facts about this version of the natural numbers that require induction.
If you’re still curious, you might figure out how to tackle logical OR, logical NOT, and lists. (The eliminator of a list is its foldr function specialized to that list.)
These simulations are intellectually interesting, but they can be inefficient (especially the natural numbers), and they are somewhat awkward to use. Furthermore, there are certain things we would like to prove but cannot (for example, that simulated 0 is not equal to simulated 1). For these reasons, it makes sense to add these features directly, which we do below. As we will see, Agda and Coq provide a general mechanism to define new recursive datatypes. Rather than leap to this level of generality, we will create each of our datatype additions to Proust individually, to gain intuition before moving to these languages and their more general settings.
3.2.7 Neither proof nor assistant
We designed Proust as a proof assistant,
but it cannot reliably prove things because of
The easiest (but not most general) way to fix
A for-all type (or Arrow type in Proust’s code)
can quantify over something whose type is a sort (previously it had to be
That’s the extent of the changes, only a few lines,
but a bit hard to motivate.
The result is a two-sorted pure type system that implements
the Calculus of Constructions in its original form.
This turns out to not be quite enough when one starts to seriously work through proofs,
and one runs across situations where it would be nice to quantify over
Challenge: adapt proust-pred-forall-works.rkt to implement each of these ideas.
Both Coq and Agda maintain a universe hierarchy, and hide the details from the user until they become relevant. We would do this in Proust as well if our goal was a fully-usable proof assistant. Certain concepts (equality, for example) show up in multiple universes, and it would be nice to just implement them once. This is known as "universe polymorphism".
Bringing back holes and refinement is not a matter of a few lines of code.
The basic idea is to replace each hole with a metavariable,
and then collect equations relating these metavariables during typechecking.
This is also done in Hindley-Milner type inference (as implemented in OCaml and Haskell),
and the algorithm to solve the equations is called unification.
In the case of Hindley-Milner type inference,
the algorithm is more or less the obvious one.
If
But that is first-order unification, with a fixed set of type constructors. If a metavariable could show up in constructor position, we have higher-order unification, which is undecidable. This is roughly what happens when dependent types are involved. Coq and Agda use algorithms that are not guaranteed to work, but which mostly work well in practice. At times they will report a "cannot unify" error. Sometimes this is because the equations cannot be solved, and sometimes it is because the algorithm is inadequate.
A later, optional chapter will expand on the last two paragraphs, and the Resources chapter has suggestions if you wish to read more about these topics from primary sources.
3.2.8 Equality, Booleans, and arithmetic
It’s worth summarizing what Proust code needs to be added to when we add a new feature.
Parsing, pretty-printing;
Alpha equivalence;
Substitution, free variable check;
Reduction;
Type checking and synthesis.
First, let’s add the equality relation, because without some relation, we can’t form logical statements about values. The equality relation is the most general and useful, though perhaps not the easiest to add. The code is available in proust-pred-eq.rkt.
We have two notions of equality already:
structural equality (equal? in Racket)
and definitional equality (equiv? in Proust).
To these we will add propositional equality,
which is how we talk about equality in propositions,
and how references to it are interpreted.
Our implementation of this adds syntax to let user code
access our previously-internal notion of definitional equality.
In our term language, we will use the traditional infix
expr | = | ... | ||
| | (expr = expr) |
We parse this sort of expression into a new two-field Teq structure. Type expressions using equality are validated with the following inference rule.
This is what the code does.
But if the turn rule is used for
The term for equality introduction is formed
by a new infix constructor with one argument, eq-refl,
which witnesses reflexivity
(
expr | = | ... | ||
| | (eq-refl expr) |
At first glance, eq-refl seems useless.
What good is a proof of
How do we use an equality in an informal proof? We do substitution of "equals for equals".
For example, we might have the equality
We don’t yet have natural numbers or addition implemented in our system. But we’re used to thinking about them, so let’s derive the elimination principle for equality by starting with the above example, and gradually generalizing it.
If we just wanted to use the equality
So let’s construct a formula
Another way of thinking of this is that
We can now try to write out the type of the equality eliminator
as specialized for this particular equality.
If we have a proof of
Now let’s generalize. Instead of just
We can similarly generalize
Finally, we generalize to equalities over arbitrary types instead of just
our not-yet-existent
So, to use or eliminate a proof of an equality, we provide
Since we have to write out an explicit proof term using this eliminator in Proust,
we can make it a little less tedious by synthesizing the type
expr | = | ... | ||
| | (eq-elim expr expr expr expr expr) |
And here is the inference rule:
How do we use such a rule in practice?
Here is one possible approach.
Suppose we have a proof
There are a lot of changes to the code, but most of them are straightforward as we go through the checklist: parsing, pretty-printing, alpha equivalence, substitution, and the free variable check. The two interesting changes involve type synthesis and reduction. The code for type synthesis essentially checks the various parts of the type signature we have figured out for the eliminator. (There are other changes to type-synth, for example, to synthesize the type of an equality, but they are more straightforward.)
(define (type-synth ctx expr) |
(match expr |
... |
[(Teq e1 e2) |
(define t1 (type-synth ctx e1)) |
(type-check ctx e2 t1) |
(Type)] |
[(Eq-refl x) (type-synth ctx x) (Teq x x)] |
[(Eq-elim x P px y peq) |
(define A (type-synth ctx x)) |
(type-check ctx P (Arrow '_ A (Type))) |
(define Pann (Ann P (Arrow '_ A (Type)))) |
(type-check ctx px (App Pann x)) |
(type-check ctx y A) |
(type-check ctx peq (Teq x y)) |
(App Pann y)] |
...)) |
Following a suggestion of Astra Kolomatskaia, we type the application of Eq-elim not as (P y) but as (Pann y), where Pann is P annotated with its (checked) type. The reason for this is that in practice, P will often be a lambda, and this saves the user the burden of annotating it.
How do these new sorts of terms behave under reduction, which is where computation takes place? The result of reducing (Eq-refl x) is (Eq-refl (reduce x)). What about (Eq-elim x P px y peq)? We start by reducing peq. If the result is of the form (Eq-refl z), then both x and y must be definitionally equal to z. The result is supposed to be a proof of (P y). Given what we have learned, px is that proof, and we reduce that. On the other hand, if peq does not reduce to an application of Eq-refl, then we reduce the other arguments and wrap them up in Eq-elim again.
The code so far is linked in the Handouts chapter as proust-pred-eq.rkt. We can’t do much with this yet, because we don’t have many datatypes to work with. We do have the identity function, and the simulation of Booleans, which we can use in a few simple equalities.
(def 'id-eq-id '((eq-refl id) : (id = id))) |
(def 't-and-t-is-t '((eq-refl true) : ((band true true) = true))) |
Exercise 10: State and prove the properties that equality is symmetric and transitive.
Our definition of propositional equality in terms of definitional equality is not the only possibiity, and working with it for a while will raise questions in one’s mind. For example, it does not capture the mathematical notion that two functions are equal if and only if they are pointwise equal. Equivalence classes are used a lot in higher mathematics, and one might want a definition of equality that could more easily accommodate user-defined equivalence classes and the notion of quotients on sets. Homotopy type theory is one currently popular approach that provides an interesting alternative to what I have presented here.
We can do more with equality when we add more datatypes, which we will do right now.
To add Booleans directly (as opposed to simulations), we add to the term language the constants true, false, and Bool (which is the type of true and false). We parse these into new zero-field structures in an AST.
expr | = | ... | ||
| | true | |||
| | false | |||
| | Bool |
The introduction rules for these are pretty straightforward.
Let’s think about the eliminator for Boolean values. In the simulation above, we used if-then-else. But I warned you that those simulations were limited. Here’s the definition we used in the simulation. Focus on the type.
(def 'if '((λ X => (λ b => (λ t => (λ f => (((b X) t) f))))) |
: (∀ (X : Type) -> (bool -> (X -> (X -> X)))))) |
This is very much a computational definition. In effect, the eliminator consumes a Boolean value (the test of the if), and two values of the same type X (the "then" and "else" clauses) and produces one of them (which has type X). We are working on a proof assistant, so we also need ways of proving things about Boolean values. But the computational aspect is more familiar to us, so let’s stick with it for a bit.
Do the two clauses (then-else) need to have the same type? That is the practice in most statically-typed programming languages, but we have dependent types in our language, which most languages do not.
Suppose we try to have the two clauses produce different types, say T and W respectively. Then what would the result type be? It would depend on the value of the Boolean test. That’s where dependent types can be useful. If the Boolean test value is b, then the result type is something like "if b then T else W".
To express this using dependent types,
we need to create a function
So the type of the eliminator seems to be
Substituting for
Bringing the
We did this from a computational point of view, to make if-then-else more general, and we still have to figure out how to implement it. What about the ability to prove things? It turns out that this eliminator is exactly what is needed.
The function
This seems like common sense. To prove that something holds for all members of a finite set (example: Booleans), show that it holds for each member of that set. The eliminator is formalizing this intuition. It can be viewed as an induction principle for Booleans. You know induction primarily as a way of proving for-all statements about the natural numbers. But the idea can be generalized to other types, even types such as Boolean that have a simple non-recursive definition (in effect, the definition only has base cases). Although this eliminator seems straightforward, the equivalent cannot be proved for our simulation of Booleans (another reason to add them directly).
We’ll call the elimination form in the term language Bool-ind,
and it will have four arguments:
expr | = | ... | ||
| | (Bool-ind expr expr expr expr) |
Again, most of the changes resulting from this addition to the grammar are straightforward, though we have to go through the checklist and make sure they’re all done. The interesting ones are type synthesis and reduction. The additional code for type synthesis pretty much checks the type signature we worked out for the eliminator.
(define (type-synth ctx expr) |
(match expr |
... |
[(Bool-ind P tp fp b) |
(type-check ctx P (Arrow '_ (Bool) (Type))) |
(define Pann (Ann P (Arrow '_ (Bool) (Type)))) |
(type-check ctx tp (App Pann (True))) |
(type-check ctx fp (App Pann (False))) |
(type-check ctx b (Bool)) |
(App Pann b)] |
...)) |
What would the result of reducing (Bool-ind P tp fp b) be? If reducing b does not yield one of true or false, all we can do is reduce the other arguments and then wrap them up again in a Bool-ind structure (in other words, use structural recursion). But if it yields true, then the result should have type (P true), that is, it should be the reduction of tp. The other case should now be obvious.
The similarity of the handling of the eliminator for equality and the eliminator for Booleans should now be more evident, and can serve as a guide for future additions. This code is linked in the Handouts chapter as proust-pred-bool.rkt.
Exercise 11: Define the Boolean
This is the third time we have implemented computation in Proust, and it should now be clear what they have in common. Computation happens when an eliminator for a type is directly applied to a constructor for that type. With a for-all type, the eliminator is function application, and the constructor is lambda. If a lambda is directly applied to an expression, we substitute the expression into the body of the lambda and continue to reduce. Similarly, reduction makes computational progress when the eliminator eq-elim is applied to a proof of equality that is of the form (eq-refl x), and when the eliminator Bool-ind is applied to a Boolean expression that is either true or false. This pattern will continue with later additions.
With Booleans, we started with a computational notion, to generalize if-then-else to the dependent setting, and came up with an eliminator that is useful for proving things. We could also have gone the other way, starting with an induction principle for Booleans and specializing it to a form that is computationally useful. That approach will pay off as we move to natural numbers, because if you had ever seen induction before this stroll, chances are it was for natural numbers. I will sketch the ideas for the implementation of proust-pred-nat.rkt and you will complete it as an exercise.
We saw induction on lists in the Introduction chapter.
Induction on natural numbers is even simpler.
To prove a property holds for all natural numbers,
prove that it holds for zero,
and prove that for all
(struct Z ()) |
(struct S (pred)) |
(define zero (Z)) |
(define one (S (Z))) |
(define two (S (S (Z)))) |
(define (plus x y) |
(match x |
[(Z) y] |
[(S w) (S (plus w y))])) |
This idea goes back to Hermann Grassman (1861), though it was made famous by Giuseppe Peano (1889), building on work of Dedekind and Peirce. We won’t have pattern matching in Proust, (though it is a major feature in both Agda and Coq), so our definition of plus will not look the same. It will be essentially the same, only expressed differently.
We add the Z and S structures to Proust; these serve as introduction forms for natural numbers, which we will type as Nat. I’m deliberately leaving out inference rules here; I want you to write them out and convince yourself of their correctness before doing the implementation.
For the eliminator, we can reason as follows:
to prove a property of a natural number n,
it suffices to prove it for Z and show that
successor preserves the property, that is,
for any natural number
We define an eliminator Nat-ind with arguments P, zc and sc (zero case and successor case), and n. As with Booleans, the code for type synthesis does the checking expected from the signature. Details are left for you to implement in an exercise below. Notice in particular that if you are constructing the expected type of sc, the variable of quantification k should be fresh with respect to P.
What about reduction? Reducing (Nat-ind P zc sc (Z)) should clearly result in zc (reduced). What about (Nat-ind P zc sc (S m)) for some m? To get the required proof of (P (S m)), we need to get a proof of (P m) with the recursive application (Nat-ind P zc sc m), then apply (sc m) to this, according to the induction principle. This is a bit more complicated than the cases we’ve seen, but along the same lines.
I promised that if we focussed first on proving things, a computationally useful principle would result. The behaviour of Nat-ind under reduction should be reminiscent of structural recursion on a natural number. The computation of plus in Racket given above has a zero case and a successor case. We can define a single abstract function for this, much as we did when we defined foldr for structural recursion on lists. Here is an iterator for natural numbers, which we can use to define plus.
(define (nat-iter zc sc n) |
(match n |
[(Z) zc] |
[(S m) (sc (nat-iter zc sc m))])) |
(define (plus n m) (nat-iter m S n)) |
With Booleans, we got the computationally familiar if-then-else
from the induction principle by making the property
The sc function provided as an argument to the iterator
itself consumes only one argument, but in the type above,
it has two arguments. The extra
If we further specialize the recursor by having the second argument to Nat-ind ignore its first natural number argument, we get the type of the iterator:
With these ideas, we can finally define plus, and prove the natural number equivalent of the list property we proved by induction in the Introduction:
(def 'plus-zero-right '(? : (∀ (n : Nat) -> ((plus n Z) = n))))
This is going to be simple to prove if you define plus so that it does recursion on the second argument, so let’s include the other identity as well.
(def 'plus-zero-left '(? : (∀ (n : Nat) -> ((plus Z n) = n))))
Exercise 12: Starting from proust-pred-eq.rkt,
complete the implementation of natural numbers.
Exercise 13: Using your solution to exercise 12,
define plus,
and finish the definitions of plus-zero-right
and plus-zero-left.
How is all this handled in first-order logic, where we don’t have the power of polymorphism? First, several axioms need to be added for equality – for example, axioms stating that equality is reflexive, symmetric, and transitive (in our setting, we could derive all of these from eq-elim). We added a single nat-ind which quantified over all propositions, but we can’t do that in first-order logic. Instead, we need a rule that says that for every proposition, Nat-ind specialized to that proposition is an axiom. This is an axiom schema that adds an infinite number of axioms to the system. But that’s still not good enough. We need several axioms establishing basic properties of arithmetic, for example, that 0 is the left identity for addition. The precise number and form can vary somewhat, but these are called the Peano axioms. (Challenge: find some complete description of these, and show that they can all be proved in your solution to exercise 14.)
Having done all that, formal proofs in first-order logic of familiar mathematical statements tend to be quite large. There are at least two advantages our language of higher-order logic exhibits that tend to shorten proofs: first of all, the language is more expressive, and second of all, our proof checking process is more dynamic (there is a notion of computation). The computational steps have to be present in a proof tree, but they are implicit in proof terms, which can thus be smaller.
Gödel proved in 1931 that any set of first-order axioms that can prove the Peano axioms must be either inconsistent or incomplete. This involved, as you might expect, coding formulas as numbers so that one can talk about proofs within the logical language. He also proved that any such system, if it is consistent, cannot prove that the Peano axioms are consistent. These are the famous incompleteness theorems, and it is worth reading one of the many accessible presentations of this topic at some point. These results heavily influenced Church and Turing’s 1936 work. Gentzen also showed, around the same time, that Peano arithmetic was consistent, though the method he used (transfinite induction) cannot be expressed in first-order Peano arithmetic.
We have twelve years of great mathematics from Gentzen. His post-PhD career coincided almost exactly with the Third Reich in Germany. Involvement with the Nazi party and its extensions was necessary for academic advancement. Unfortunately, Gentzen secured a teaching post in Prague, within occupied Czechosolvakia. Prague was liberated by the Russians in 1945, and Gentzen was incarcerated. The Russians routinely mistreated their political prisoners, and after three months, Gentzen died of malnutrition.
When the war broke out, Gödel was in Vienna, in Austria, which had been annexed by Germany the year before. He left for America by way of the Trans-Siberian railroad and a boat from Japan to California, and spent the rest of his life at the Institute for Advanced Study in Princeton, New Jersey. He periodically suffered from psychological issues, and late in life would only eat food prepared by his wife. When she was hospitalized, he stopped eating, and in an eerie echo of Gentzen’s fate, he also died of malnutrition.
Both the mathematical work of the logicians I have mentioned and their historical context often makes for fascinating reading. Bertrand Russell, for example, was imprisoned for pacifism during World War I, and his library was sold to pay his fines when he refused to do so. He actively campaigned for nuclear disarmament and against the war in Vietnam, and won the Nobel Prize for Literature in 1950.
Back to the present and Proust. We still have work to do: we have not handled "there exists", which we will do next. To properly finish the coding, we should put back the logical connectives (which are implemented the same way as before, though we now need to consider computational aspects when we handle them in reduction). That will be left to you.
Exercise 15:
Add the logical connectives to your solution to exercise 14.
3.2.9 Existential quantification
Here is the BHK interpretation as quoted for first-order logic:
"A proof of
Here is the modification including the domain of quantification:
"A proof of
Once again, from a type theory point of view, we can consider expressions
where
The use of the arrow as separator for the Proust term language is really not justified here, so we’ll just leave it out. We’re only going to be using the syntax for a short time.
expr | = | ... | ||
| | (∃ (x : expr) expr) |
The exists-introduction form will have these two things as arguments.
What should the inference rule look like? It sometimes helps to regard
Something similar is the case here. From a proof of
How do we use or eliminate a "there exists"?
Again, the analogy to
Here is the
To generalize this reasoning to the "there exists" case,
if we are eliminating a value of type
The preparation, then, is a function that will map
any value
expr | = | ... | ||
| | (∃-elim expr expr) |
How should reduction deal with
an expression of the form
The implementation of all of this is once again left to you.
Exercise 16:
Starting from your solution to either Exercise 14 or Exercise 15,
implement the rules for the logical constant
Exercise 17:
Use your solution to exercise 16
to define the property even for natural numbers,
and show that two is even.
As a challenge, can you show that one is not even?
Exercise 18: Using your solution to exercise 16, complete the proofs that are left as holes below.
(define not-exists-all-not '((∀ (X : Type) -> (∀ (P : (X -> Type)) -> ((¬ (∃ (x : X) (P x))) -> (∀ (x : X) -> (¬ (P x)))))) : Type)) (define answer1 '(? : not-exists-all-not)) (define exists-not-not-all '((∀ (X : Type) -> (∀ (P : (X -> Type)) -> ((∃ (x : X) (¬ (P x))) -> (¬ (∀ (x : X) -> (P x)))))) : Type)) (define answer2 '(? : exists-not-not-all)) (define not-pointwise-not-equal '(? : (∀ (A : Type) (B : Type) (f : (A -> B)) (g : (A -> B)) -> ((∃ (x : A) (¬ ((f x) = (g x)))) -> (¬ (f = g)))))) (define answer3 '(? : not-pointwise-not-equal))
When discussing natural numbers, I drew a distinction between an iterator, a recursor, and an induction principle, in increasing order of generality. The eliminator I have defined above for existential types suffices to solve the exercises above, but it is a recursor; that is, it is not the most general eliminator. If we view a value with an existential type as a dependent pair, and wish to do computations with this datatype, it is not powerful enough. As an example, we can use it to write a function which extracts the first element of such a pair (the first projection function). But we cannot write a function which extracts the second element!
For completeness, I will give the induction principle for existential types, and leave it as an optional exercise for you to verify that it generalizes the eliminator above, to implement it in Proust, and to use it to define the two projection functions.
Astra Kolomatskaia suggests that since this use of existential types generalizes the non-dependent pairs one gets from propositional AND, we might use the syntax ∃ (x : T) × W with the symbol for Cartesian product as a separator, just as we used arrow as a separator for universal types. This syntax appears nowhere else that I know of, but you may have fun experimenting with it.
Given the analogy between
There is a lot more we can do with Proust, and you are welcome to improve it as you see fit. For example, given our treatment of natural numbers, you should be able to add lists (polymorphic in the element type). But this may be a good point to move to a full-featured proof assistant. Many of the improvements they offer would be a challenge to implement (though an interesting one). We now have enough experience with the ideas that you can understand the basics of what Agda and Coq are doing, so let’s take a look at them.
To be clear: we are not done with predicate logic, even though the chapter is ending. We need more coverage of its use in the formalization of reasoning about mathematics and about computation. But the limitations of our tool would make that coverage more difficult, and extending the tool is also difficult. So we are switching to tools where many other people have already done the work, and we will benefit from it.
3.2.10 Summary of rules for intuitionistic predicate logic
Repeated from propositional logic, if desired: the rules for the logical connectives except implication. The Var and Weaken rules given below together properly describe lookup in contexts.
Type-in-Type (causes inconsistency):
For-all:
An alpha-equivalent proof term (with freshness conditions as specified in the text) may be substituted.
Implication is a special case of for-all.
Equality:
Booleans:
Existential quantification:
Not included: the definition of type equivalence (