On this page:
4.1 Equality
4.1.1 Observational Equivalence
4.1.2 Establishing Inequivalence
4.2 Normal Forms
4.3 Formalism
4.3.1 Inference Rules and Derivation Trees
4.3.2 Derivation Trees and Running Programs by Hand
4.3.3 Big-Step Semantics
4.3.4 From Inference Rules to Implementation
4.4 Relating the Big and Small-Step Semantics
Bibliography
9.3

4 Compilation (cont’d) and formal reasoning🔗

Last time we ended on encoding BoolCalc into Lam, the lambda calculus. Let’s restate these languages, as well as the encode/decode operations:

> (define-type BoolCalc
    (trueE)
    (falseE)
    (notE [e : BoolCalc])
    (andE [e1 : BoolCalc] [e2 : BoolCalc]))
> (interp-bool : (BoolCalc -> Boolean))
> (define (interp-bool e)
    (type-case BoolCalc e
               [(trueE) #t]
               [(falseE) #f]
               [(notE e) (not (interp-bool e))]
               [(andE e1 e2) (and (interp-bool e1)
                                  (interp-bool e2))]))
> (define-type Lam
    [identE (id : Symbol)]
    [lamE (arg : Symbol) (body : Lam)]
    [appE (e1 : Lam) (e2 : Lam)])
> (define-type Value
    [lamV (id : Symbol) (body : Lam)])
> (subst : (Symbol Value Lam -> Lam))
> (define (subst id assignment body)
    (type-case Lam body
               [(identE x)
                (if (equal? id x)
                    (lamE (lamV-id assignment) (lamV-body assignment))
                    (identE x))]
               [(appE e1 e2)
                (appE (subst id assignment e1)
                      (subst id assignment e2))]
               [(lamE x body)
                (if (equal? x id)
                    (lamE x body)
                    (lamE x (subst id assignment body)))]))
> (interp-lam : (Lam -> Value))
> (define (interp-lam e)
    (type-case Lam e
               [(identE x) (error 'runtime "free variable")]
               [(lamE id body) (lamV id body)]
               [(appE e1 e2)
                (let* [(v1 (interp-lam e1))
                       (v2 (interp-lam e2))
                       (id (lamV-id v1))
                       (body (lamV-body v1))
                       (subst-body (subst id v2 body))]
                  (interp-lam subst-body))]))
> (define Ltrue (lamE 'x (lamE 'y (identE 'x))))
> (define Lfalse (lamE 'x (lamE 'y (identE 'y))))
> (encode : (BoolCalc -> Lam))
> (define (encode e)
    (type-case BoolCalc e
               [(trueE) Ltrue]
               [(falseE) Lfalse]
               [(notE e)
                (let [(encodedE (encode e))]
                  (appE (appE encodedE Lfalse) Ltrue))]
               [(andE e1 e2)
                (let [(encoded-e1 (encode e1))
                      (encoded-e2 (encode e2))]
                  (appE (appE encoded-e1 encoded-e2) Lfalse))]))
> (define (lam->value v)
    (lamV (lamE-arg v) (lamE-body v)))
> (test (interp-lam (encode (trueE)))
        (lam->value Ltrue))

- Void

good (interp-lam (encode (trueE))) at line 16

  expected: (lamV 'x (lamE 'y (identE 'x)))

  given: (lamV 'x (lamE 'y (identE 'x)))

> (test (interp-lam (encode (andE (trueE) (trueE))))
        (lam->value Ltrue))

- Void

good (interp-lam (encode (andE (trueE) (trueE)))) at line 17

  expected: (lamV 'x (lamE 'y (identE 'x)))

  given: (lamV 'x (lamE 'y (identE 'x)))

> (test (interp-lam (encode (andE (falseE) (trueE))))
        (lam->value Lfalse))

- Void

good (interp-lam (encode (andE (falseE) (trueE)))) at line 18

  expected: (lamV 'x (lamE 'y (identE 'y)))

  given: (lamV 'x (lamE 'y (identE 'y)))

> (test (interp-lam (encode (andE (andE (trueE) (falseE)) (trueE))))
        (lam->value Lfalse))

- Void

good (interp-lam (encode (andE (andE (trueE) (falseE)) (trueE)))) at line 19

  expected: (lamV 'x (lamE 'y (identE 'y)))

  given: (lamV 'x (lamE 'y (identE 'y)))

In class, we defined a decode function defined purely in terms of notation, and not code. Because of this we constructed a calling convention where we applied lambda terms to elements of BoolCalc’s (trueE) and (falseE). While this a valid thing we can do on pen-and-paper, understanding decode is quite a bit more subtle than what we stated last lecture. This motivates our notion of equality.

4.1 Equality🔗

Suppose someone gave you a compiler called mystery-encoder that compiled (trueE) to \lambda a . \lambda b . a. This isn’t syntactically equivalent to Ltrue, and such a compiler would fail our above test cases, since they are testing for syntactic equality. This seems undesirable: we would like compiler writers to have the freedom to use whichever variable names for encodings that they like.

Even worse, your friend has a very creative compiler called mystery-encoder2 that compiles (trueE) to \lambda a . \lambda b . ((\lambda z . z) ~ a). Is this compiler wrong? At first it may seem so, but look closely: this strange encoding behaves identically to Ltrue. So, intuitively, it seems like this compiler should also be correct.

The above discussion motivates one of the most important concepts in the lambda calculus: equality of lambda terms.

The simplest notion of equivalence is syntactic equivalence, also called \alpha-equivalence. Two terms are \alpha-equivalent if they have identical abstract syntax trees. Our above test cases check for \alpha-equivalence. But syntactic equivalence seems too strong: we would like to accept the output of mystery-encoder2 as a valid compiler, since it is intuitively behaving correctly (albeit inefficiently).

4.1.1 Observational Equivalence🔗

What does it mean for two lambda programs to be semantically equivalent? An intuitive notion of equivalence is that two lambda calculus programs should be equivalent if there is no way to tell them apart by calling them.

Observational equivalence is sometimes also called "behavioral equivalence" or "extensional equivalence".

This sounds nice, but it’s a bit too informal: what does it mean to "tell two function calls apart"? To make this more precise, we need a notion of an observable behavior: a property that we can see about a program as it runs. You’re all familiar with observable properties of programs: these are typically things like writing text to the console, showing something on the screen, or timing how long a function takes to run. To tell if two programs are equivalent, we will fix some set of observable behaviors, and two programs will be semantically equivalent if they exhibit the same behavior. This is called observational equivalence. We typically denote observational equivalence with the symbol \cong.

So, what should be our notion of observable behavior for comparing equivalence of lambda calculus programs? Well, our lambda calculus can do very few things, so we don’t have many choices about what we can or cannot observe. Let’s take a leap of faith: let’s decide that we can observe whether or not a lambda calculus program terminates.

4.1.2 Establishing Inequivalence🔗

How do we know if this is a decent notion of observation? Well, we can use it to check whether or not programs we want to be equivalent or inequivalent are consistent with this choice. Let’s start by looking at two programs we assume should not be semantically equivalent: Ltrue and Lfalse. How can we tell if these two programs are equivalent or not by calling them? We need to find a particular way to call them that exhibits different termination behavior.

To do this, let’s define a function called poison-pill that loops forever when it is called, i.e., it simply calls \Omega:

\Omega = (\lambda x . x ~ x) ~ (\lambda x . x ~ x)

\texttt{poison-pill} = \lambda z . \Omega = \lambda z . ((\lambda x . x ~ x) ~ (\lambda x . x ~ x))

How can we use this function to distinguish Ltrue from Lfalse?

Observe, the following two programs have different terminating behavior (where we’ve shorted \lambda x . x to \texttt{id}):

(\texttt{Ltrue} ~ \texttt{poison-pill} ~ \texttt{id}) ~ \texttt{id} (\texttt{Lfalse} ~ \texttt{poison-pill} ~ \texttt{id}) ~ \texttt{id}

The first one runs forever, and the second one doesn’t. Carefully think about why. So, this establishes that these two programs are observationally inequivalent.

4.2 Normal Forms🔗

We’ve seen how to establish that two programs are observationally inequivalent, but how can we establish that they’re observationally equivalent? Ideally, we would have a way of checking whether or not an arbitrary lambda term is observationally equivalent to \texttt{Ltrue} or \texttt{Lfalse}; then we could finally implement our decoder function.

The key to establishing the observational equivalence of two lambda terms is to turn the question into a search problem by successively transforming one program until it is syntactically equal to the other in a way that preserves termination behavior with each step.

For instance, suppose we want to show that e_1 = \lambda x . ((\lambda y . y)~x) is observationally equivalent to e_2 = \lambda z . z. What can we do to transform e_1 into e_2 without affecting its termination behaivor?

Well, first we can apply a beta reduction under the lambda:

You’re probably thinking this looks a lot like small-step semantics, but it is not. We are establishing a sequence of observationally equivalent programs here; we are not describing how a program is to be evaluated. Note that the above rule is actually not a valid step in our semantics.

e_1 = \lambda x . ((\lambda y . y)~x) \cong \underbrace{\lambda x . x}_{e_1{}’}

Note that this step of simplification preserves termination behavior: \beta-reduction cannot turn a terminating program into a non-terminating one or vice versa.

Now, e_1{}’ is still not syntactically equal to e_2. But, it is clear that they are \alpha-equivalent to each other. So, we can apply \alpha-renaming to rename the variables in e_1{}’ to be syntactically equal to e_2; it’s clear that \alpha-renaming also preserves termination behavior.

The above suggests the following algorithm for checking whether or not two programs e_1 and e_2 are observationally equivalent, which is called the method of normal forms:

  • First, fully simplify both e_1 and e_2 by applying all possible \beta-reductions. If this simplification never terminates for both, then conclude they are equivalent; if it terminates for one but not the other, conclude they are inequivalent.

  • Then, try to use \alpha-renaming to relabel the fully-simplified programs so that they are syntactically equivalent. If this succeeds, they are equivalent; if it doesn’t, then they are inequivalent.

There are a few more low-level details about how precisely these two steps are implemented, which you will see in your upcoming homework assignments and we will discuss briefly, next.

4.3 Formalism 🔗

We have reached a point now where we have quite a complicated little garden of languages, each with their own syntax and semantics. It is time to bring r to this situation by introducing a concise and powerful system of notation for describing a program’s behavior. This new notation will empower us to quickly and precisely write down a description of a program’s semantics. As we move through the course, you will see more of this notation as we use it to describe type systems and other facets of how programs operate.

This is heavily based on Chapter 3 of  (Pierce 2002).

4.3.1 Inference Rules and Derivation Trees🔗

Let’s go back to our CalcLang example we’ve been studying since the first lecture:

> (define-type CalcLang
    (numE [value : Number])
    (addE [left : CalcLang] [right : CalcLang]))
> (define (interp e)
    (type-case CalcLang e
      [(numE n) n]
      [(addE e1 e2) (+ (interp e1) (interp e2))]))

We’ve seen two examples of how to give semantics to CalcLang: big-step and small-step. Let’s start with the small-step semantics. Recall the surface syntax for our calculator language:

 

‹expr›

 ::= 

( + ‹expr› ‹expr› )

 

  |  

‹n›

Figure 4: Surface syntax for CalcLang.

Recall we had the following English description of small-step semantics:

  • Numbers are values and step to themselves.

  • To step (addE e1 e2), if e1 and e2 are both numbers then step to (numE (+ e1 e2)); if e1 is a number then step e2; if e1 isn’t a number then step e1.

This is quite a mouthful, and it is a bit awkward to describe these rules in English. So, we will introduce the following notation to summarize these rules:

Inference rules can be tricky the first time you see them! It’s good to get some practice using them.

\dfrac{~}{\texttt{(+ n1 n2)} \rightarrow \texttt{n1 + n2}}~\text{(E-Add1)}\dfrac{\texttt{e1} \rightarrow \texttt{e1{}’} }{\texttt{(+ e1 e2)} \rightarrow \texttt{(+ e1{}’ e2)}}~\text{(E-Add2)}\dfrac{\texttt{e2} \rightarrow \texttt{e2{}’} }{\texttt{(+ n e2)} \rightarrow \texttt{(+ n e2{}’)}}~\text{(E-Add3)}

Figure 5: Small-step semantics for CalcLang.

Obviously this notation is unfamiliar, so we need to define what it means. An inference rule is a collection of premises followed by a conclusion and separated by a horizontal line. For example, the following is a simple inference rule that says that if it is raining and you have no umbrella, then you are wet:

\dfrac{\text{It is raining.} \quad \text{You have no umbrella.}}{\text{You are wet.}}

If an inference rule has no premises, then it is called an axiom. Axioms are always true. The following is an example of an axiom:

\dfrac{~}{\text{The sun is hot.}}

A question you may ask is: what is the meta-language for interpreting the symbols in these inference rules? This is a very good question! In general, we will assume that Plait is still how we interpret data manipulation (so, addition, branching, etc. are all Plait).

The figure above uses inference rules to describe a relation between abstract syntax of CalcLang: a syntactic term e1 is related to a syntactic term e2 if e1 steps to e2. The above inference rules contain meta-variables in them like n and e1; these are symbols that can be replaced when an inference rule is applied. The meta-variable n stands for a number, and the meta-variables like e1 stand for expressions.

Going through each of these rules one at a time:

  • (E-Add1) handles the case when both arguments are numbers. It is an axiom: it says that if both arguments to addE are numbers, then (addE n1 n2) steps to (numE (+ n1 n2)), where the + is the meta-language addition.

  • (E-Add2) handles says that if e1 can take a step to some other expression e1’, then (+ e1 e2) steps to (+ e1’ e2).

  • (E-Add3) says that if e2 can take a step to e2’ then (+ n e2) steps to (+ n e2’). Note that the metavariable n is used as the first argument to +: this indicates that the first argument must be a number in order for this step to apply.

Note that these inference rules are deterministic: this means that at most 1 inference rule can apply to any given syntactic term.

4.3.2 Derivation Trees and Running Programs by Hand🔗

Back to small-step semantics. The process of applying an inference rule typically involves substituting metavariables for bound variables: this process is called unification. For example, suppose we want to determine what the following CalcLang program steps to:

(+ 10 20)

To do this, we need to determine which of the above inference rules apply to this surface syntax. We do this by process of elimination:
  • The (E-Add1) rule does apply because the syntax (+ 10 20) matches the expression on the left-hand side of the \rightarrow symbol. Concretely, the syntax 10 unifies with metavariable n1 and the syntax 20 unifies with metavariable n2.

So, we can draw the following derivation that shows how to apply the (E-Add1) rule to take a step for the above term:

\dfrac{~}{\texttt{(+ 10 20)} \rightarrow \texttt{30}}~\text{(E-Add1)}

It is typical to write the name of the applied inference rule whenever it is used, but it may sometimes be elided for brevity, especially in the case of axioms.

One of the key properties of inference rules is that they can be chained together, conclusions of one rule feeding into premises of the next rule. For instance, we can chain together the (E-Add1) and (E-Add2) rule to establish a more complicated fact:

\dfrac{\dfrac{~}{\texttt{(+ 1 2)} \rightarrow \texttt{3} }~\text{(E-Add1)}} {\texttt{(+ (+ 1 2) 2)} \rightarrow \texttt{(+ 3 2)}}~\text{(E-Add2)}

This structure is called a derivation tree. At the bottom of the tree is the final conclusion. A derivation tree is called a valid derivation tree if each the leaves of the tree are all axioms, i.e. statements that hold without premises. Notice how in the above derivation tree, the metavariable e1 for the (E-Add2) rule unifies with the syntax (+ 1 2), and the metavariable e2 unifies with the syntax 2.

4.3.3 Big-Step Semantics🔗

Recall our definitional interpreter for CalcLang:

> (interp : (CalcLang -> Number))
> (define (interp e)
    (type-case CalcLang e
               [(numE n) n]
               [(addE e1 e2)
                (+ (interp e1) (interp e2))]))

A big-step semantics relates terms to the values that they run to. We can use our inference rule notation to describe this relation in a manner similar to the small-step semantics. We will denote a program e running to value v as e \Downarrow v.

\dfrac{~}{\texttt{n} \Downarrow \texttt{n}}~\text{(B-Num)}\dfrac{\texttt{e1} \Downarrow v_1 \quad \texttt{e2} \Downarrow v_2 }{\texttt{(+ e1 e2)} \Downarrow v_1 + v_2}~\text{(B-Add)}

Figure 6: Big-step semantics for CalcLang.

Now we can use these inference rules to draw derivation trees that describe running CalcLang programs to values:

\dfrac{\dfrac{~}{1 \Downarrow 1}~\text{(B-Num)} \qquad \dfrac{~}{2 \Downarrow 2}~\text{(B-Num)}}{\texttt{(+ 1 2)} \Downarrow 3}~\text{(B-Add)}

Another example:

\dfrac{ \dfrac{ \dfrac{~}{1 \Downarrow 1} \quad \dfrac{~}{2 \Downarrow 2} } {\texttt{(+ 1 2)} \Downarrow 3}~\text{(B-Add)} \quad \dfrac{~}{3 \Downarrow 3}~\text{(B-Num)}} {\texttt{(+ (+ 1 2) 3)} \Downarrow 6}~\text{(B-Add)}

Observe: this derivation tree looks exactly like a trace of running your definition interpreter program interp. In this way, one can think of the big-step semantics as a concise shorthand for describing how definitional interpreters behave.

4.3.4 From Inference Rules to Implementation🔗

Inference rules are a specification language: they are an improvement over English for describing the semantics of programs. So, it is important to know how to translate inference rules into implementations.

The inference rules defining the small-step semantics for CalcLang specify a function from expressions to expressions. This exactly defines an interpreter as follows:

(step : (CalcLang -> CalcLang))
(define (step e)
  (type-case CalcLang e
             [(addE e1 e2)
              (cond
                [(and (numE? e1) (numE? e2))
                 ;-------------------------
                 ; (+ n1 n2) -> n1 + n2
                 (numE (+ (numE-value e1) (numE-value e2)))]
                [(numE? e1)
                 ; e2 -> e2'
                 ; -----------------------
                 ; (+ n e2) -> (+ n e2')
                 (addE e1 (step e2))]
                [else
                 ; e1 -> e1'
                 ; ------------------------
                 ; (+ e1' e2)
                 (addE (step e1) e2)])
              ]))

Notice how each arm of the type-case corresponds to a particular application of an inference rule.

4.4 Relating the Big and Small-Step Semantics🔗

So far, we’ve seen the big-step and the small-step semantics as two separate means of defining semantics for programs. But, intuitively, these two semantics ought to agree. The formal relationship we want is that if one repeatedly performs small steps until a term is fully-simplified, then this fully-simplified value is equal to value given by the big-step semantics.

Let’s make this relationship precise by defining an additional relation \rightarrow^* (called the transitive closure of the step relation, sometimes also called "star step" or "multi-step") that repeatedly performs small steps. We can define this using the small-step semantics as:

\dfrac{e \rightarrow e{}’ \quad e{}’ \rightarrow^* e{}’{}’}{e \rightarrow^* e{}’{}’}~\text{(E-Trans)} \qquad \dfrac{~}{v \rightarrow^* v}~\text{(E-Refl)}

Figure 7: The multi-step relation for small-step semantics.

The (E-Trans) rule, short for transitivity, says e multi-steps to e{}’{}’ if there is some intermediate e{}’ that e steps to that multi-steps to e{}’{}’. The (E-Refl) rule, short for reflexivity, says if a value multisteps to itself. We implemented this relation in the step-interp function in a previous lecture: The Stepper: Simplifying Calculator Programs.

Let’s see an example:

\dfrac{ \dfrac{\dfrac{~}{\texttt{(+ 1 2)} \rightarrow 3}~\text{(E-Add1)}} {\texttt{(+ (+ 1 2) 3)} \rightarrow \texttt{(+ 3 3)}}~\text{(E-Add2)} \quad \dfrac{ \dfrac{~}{\texttt{(+ 3 3)} \rightarrow 6}~\text{(E-Add1)} \quad \dfrac{~}{6 \rightarrow^* 6}~\text{(E-Refl)} } {\texttt{(+ 3 3)} \rightarrow^* 6}~\text{(E-Trans)} } {\texttt{(+ (+ 1 2) 3)} \rightarrow^* 6}~\text{(E-Trans)}

This theorem justifies our providing only small-step semantics for the lambda calculus above: we can define the big-step semantics using \rightarrow^*.

Then, we can prove the following theorem that formally relates these two definitions of semantics:

Theorem: For any calculator program e, e \rightarrow^* n if and only if e \Downarrow n.

We won’t prove this theorem right now, but the proof is a structural inductive argument not unlike the one we did for showing that all CalcLang programs terminate. It’s not too difficult, but it is quite tedious, since it requires handling many cases.

Bibliography🔗

Benjamin Pierce. Types and Programming Languages. 2002.