Built with Alectryon, running Coq+SerAPI v8.19.0+0.19.3. Bubbles () indicate interactive fragments: hover for details, tap to reveal contents. Use Ctrl+↑ Ctrl+↓ to navigate, Ctrl+🖱️ to focus. On Mac, use instead of Ctrl.

Week 14: Proof by reflection

Author
Adam Chlipala, with modifications by CS-628 and CS 6115 staff.
License
No redistribution allowed (usage by permission in CS 6115).

In this lecture, we will study a technique called proof by reflection. We will write, in Gallina (the logical functional-programming language of Coq), decision procedures with proofs of correctness, and we will appeal to these procedures in writing very short proofs. Such a proof is checked by running the decision procedure. The term reflection applies because we will need to translate Gallina propositions into values of inductive types representing syntax, so that Gallina programs may analyze them, and translating such a term back to the original form is called reflecting it.

Proving Evenness

As another example, let's consider proving eveness. Proving that a particular natural-number constants are even is certainly something we would rather have happen automatically. The Ltac-programming techniques that we learned previously make it easy to implement such a procedure.

Inductive isEven : nat -> Prop :=
| Even_O : isEven O
| Even_SS : forall n, isEven n -> isEven (S (S n)).


isEven 8

isEven 8
repeat constructor. Qed. Set Printing All.
even_8_repeat = Even_SS (S (S (S (S (S (S O)))))) (Even_SS (S (S (S (S O)))) (Even_SS (S (S O)) (Even_SS O Even_O))) : isEven (S (S (S (S (S (S (S (S O))))))))
Unset Printing All.

isEven 256

isEven 256
repeat constructor. Qed. Set Printing All.
even_256_repeat = Even_SS (S (S (S (S (S (S (S (S (S (S (S (S (...))))))))))))) (Even_SS (S (S (S (S (S (S (S (S (S (S (S (...)))))))))))) (Even_SS (S (S (S (S (S (S (S (S (S (S (...))))))))))) (Even_SS (S (S (S (S (S (S (S (S (S (...)))))))))) (Even_SS (S (S (S (S (S (S (S (S (...))))))))) (Even_SS (S (S (S (S (S (S (S (...)))))))) (Even_SS (S (S (S (S (S (S (...))))))) (Even_SS (S (S (S (S (S (...)))))) (Even_SS (S (S (S (S (...))))) (Even_SS (S (S (S (...)))) (Even_SS (S (S (...))) (Even_SS (S (...)) (Even_SS (...) (...))))))))))))) : isEven (S (S (S (S (S (S (S (S (S (S (S (S (...)))))))))))))
Unset Printing All.

Here we see a term of Coq's core proof language, which we won't explain in detail, but roughly speaking such a term is a syntax tree recording which lemmas were used, and how their quantifiers were instantiated, to prove a theorem. This Ltac procedure always works (at least on machines with infinite resources), but it has a serious drawback, which we see when we print the proof it generates that 256 is even. The final proof term has length superlinear in the input value, which we reveal with Set Printing All, to disable all syntactic niceties and show every node of the internal proof AST. The problem is that each Even_SS application needs a choice of n, and we wind up giving every even number from 0 to 254 in that position, at some point or another, for quadratic proof-term size.

It is also unfortunate not to have static-typing guarantees that our tactic always behaves appropriately. Other invocations of similar tactics might fail with dynamic type errors, and we would not know about the bugs behind these errors until we happened to attempt to prove complex-enough goals.

The techniques of proof by reflection address both complaints. We will be able to write proofs like in the example above with constant size overhead beyond the size of the input, and we will do it with verified decision procedures written in Gallina.

Fixpoint check_even (n : nat) : bool :=
  match n with
  | 0 => true
  | 1 => false
  | S (S n') => check_even n'
  end.

To prove check_even sound, we need two IH strengthenings:

  • Effectively switch to strong induction with an extra numeric variable, asserted to be less than the one we induct on.
  • Express both cases for how a check_even test might turn out.

forall n n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'

forall n n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
H: 0 < S n

isEven 0
n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
H: 1 < S n
~ isEven 1
n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
n0: nat
H: S (S n0) < S n
if check_even n0 then isEven (S (S n0)) else ~ isEven (S (S n0))
n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
H: 0 < S n

isEven 0
constructor.
n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
H: 1 < S n

~ isEven 1
inversion 1.
n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
n0: nat
H: S (S n0) < S n

if check_even n0 then isEven (S (S n0)) else ~ isEven (S (S n0))
n, n0: nat
IHn: n0 < n -> if check_even n0 then isEven n0 else ~ isEven n0
H: S (S n0) < S n

if check_even n0 then isEven (S (S n0)) else ~ isEven (S (S n0))
destruct check_even; (constructor || inversion 1); apply IHn; auto; lia. Qed.
check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n
n: nat

if check_even n then isEven n else ~ isEven n
check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n
n: nat

if check_even n then isEven n else ~ isEven n
check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n

isEven 0
check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n
~ isEven 1
check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n
n: nat
if check_even n then isEven (S (S n)) else ~ isEven (S (S n))
check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n

isEven 0
constructor.
check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n

~ isEven 1
inversion 1.
check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n
n: nat

if check_even n then isEven (S (S n)) else ~ isEven (S (S n))
n: nat
check_even_ok'': if check_even n then isEven n else ~ isEven n

if check_even n then isEven (S (S n)) else ~ isEven (S (S n))
n: nat
check_even_ok'': isEven n

isEven (S (S n))
n: nat
check_even_ok'': ~ isEven n
~ isEven (S (S n))
n: nat
check_even_ok'': isEven n

isEven (S (S n))
constructor; eauto.
n: nat
check_even_ok'': ~ isEven n

~ isEven (S (S n))
inversion 1; eauto. Qed.

forall n : nat, check_even n = true -> isEven n

forall n : nat, check_even n = true -> isEven n
n: nat
H: check_even n = true

isEven n
n: nat
H: check_even n = true
H0: if check_even n then isEven n else ~ isEven n

isEven n
n: nat
H: check_even n = true
H0: isEven n

isEven n
assumption. Qed.

As this theorem establishes, the function check_even may be viewed as a verified decision procedure. It is now easy to write a tactic to prove evenness.

Ltac prove_even_reflective :=
  match goal with
    | [ |- isEven _ ] => apply check_even_ok; reflexivity
  end.


isEven 256

isEven 256
prove_even_reflective. Qed. Set Printing All.
even_256' = check_even_ok (S (S (S (S (S (S (S (S (S (S (S (S (...))))))))))))) (@eq_refl bool true) : isEven (S (S (S (S (S (S (S (S (S (S (S (S (...)))))))))))))
Unset Printing All.

Notice that only one nat appears as an argument to an applied lemma, and that's the original number to test for evenness. Proof-term size scales linearly.

What happens if we try the tactic with an odd number?


isEven 255

isEven 255
The command has indeed failed with message: Unable to unify "true" with "check_even 255".

isEven 255
Abort.

Coq reports that reflexivity can't prove false = true, which makes perfect sense!

Our tactic prove_even_reflective is reflective because it performs a proof-search process (a trivial one, in this case) wholly within Gallina.

Reifying the Syntax of a Trivial Tautology Language

As a next example, let's see how to get reflective proofs of trivial tautologies.


True /\ True -> True \/ True /\ (True -> True)

True /\ True -> True \/ True /\ (True -> True)
tauto. Qed.
true_galore = fun H : True /\ True => and_ind (fun _ _ : True => or_introl I) H : True /\ True -> True \/ True /\ (True -> True)

As we might expect, the proof that tauto builds contains explicit applications of deduction rules. For large formulas, this can add a linear amount of proof-size overhead, beyond the size of the input.

To write a reflective procedure for this class of goals, we will need to get into the actual "reflection" part of "proof by reflection." It is impossible to case-analyze a Prop in any way in Gallina. We must reify Prop into some type that we can analyze. This inductive type is a good candidate:

Inductive taut : Set :=
| TautTrue : taut
| TautAnd : taut -> taut -> taut
| TautOr : taut -> taut -> taut
| TautImp : taut -> taut -> taut.

We write a recursive function to reflect this syntax back to Prop. Such functions are also called interpretation functions, and we have used them in previous examples to give semantics to small programming languages.

Fixpoint tautDenote (t : taut) : Prop :=
  match t with
    | TautTrue => True
    | TautAnd t1 t2 => tautDenote t1 /\ tautDenote t2
    | TautOr t1 t2 => tautDenote t1 \/ tautDenote t2
    | TautImp t1 t2 => tautDenote t1 -> tautDenote t2
  end.

It is easy to prove that every formula in the range of tautDenote is true.


forall t : taut, tautDenote t

forall t : taut, tautDenote t
induction t; simpl; auto. Qed.

To use tautTrue to prove particular formulas, we need to implement the syntax-reification process. A recursive Ltac function does the job.

Ltac tautReify P :=
  match P with
    | True => TautTrue
    | ?P1 /\ ?P2 =>
      let t1 := tautReify P1 in
      let t2 := tautReify P2 in
        constr:(TautAnd t1 t2)
    | ?P1 \/ ?P2 =>
      let t1 := tautReify P1 in
      let t2 := tautReify P2 in
        constr:(TautOr t1 t2)
    | ?P1 -> ?P2 =>
      let t1 := tautReify P1 in
      let t2 := tautReify P2 in
        constr:(TautImp t1 t2)
  end.

With tautReify available, it is easy to finish our reflective tactic. We look at the goal formula, reify it, and apply tautTrue to the reified formula. Recall that the change tactic replaces a conclusion formula with another that is equal to it, as shown by partial execution of terms.

Ltac obvious :=
  match goal with
    | [ |- ?P ] =>
      let t := tautReify P in
      change (tautDenote t); apply tautTrue
  end.

We can verify that obvious solves our original example, with a proof term that does not mention details of the proof.


True /\ True -> True \/ True /\ (True -> True)

True /\ True -> True \/ True /\ (True -> True)
obvious. Qed. Set Printing All.
true_galore' = tautTrue (TautImp (TautAnd TautTrue TautTrue) (TautOr TautTrue (TautAnd TautTrue (TautImp TautTrue TautTrue)))) : forall _ : and True True, or True (and True (forall _ : True, True))
Unset Printing All.

It is worth considering how the reflective tactic improves on a pure-Ltac implementation. The formula-reification process is just as ad-hoc as before, so we gain little there. In general, proofs will be more complicated than formula translation, and the "generic proof rule" that we apply here is on much better formal footing than a recursive Ltac function. The dependent type of the proof guarantees that it "works" on any input formula. This benefit is in addition to the proof-size improvement that we have already seen.

A Monoid Expression Simplifier

Proof by reflection does not require encoding of all of the syntax in a goal. We can insert "variables" in our syntax types to allow injection of arbitrary pieces, even if we cannot apply specialized reasoning to them. In this section, we explore that possibility by writing a tactic for normalizing monoid equations.

Section monoid.
  Variable A : Set.
  Variable e : A.
  Variable f : A -> A -> A.

  Infix "+" := f.

  Hypothesis assoc : forall a b c, (a + b) + c = a + (b + c).
  Hypothesis identl : forall a, e + a = a.
  Hypothesis identr : forall a, a + e = a.

We add variables and hypotheses characterizing an arbitrary instance of the algebraic structure of monoids. We have an associative binary operator and an identity element for it.

It is easy to define an expression-tree type for monoid expressions. A Var constructor is a "catch-all" case for subexpressions that we cannot model. These subexpressions could be actual Gallina variables, or they could just use functions that our tactic is unable to understand.

  Inductive mexp : Set :=
  | Ident : mexp
  | Var : A -> mexp
  | Op : mexp -> mexp -> mexp.

Next, we write an interpretation function.

  Fixpoint mdenote (me : mexp) : A :=
    match me with
    | Ident => e
    | Var v => v
    | Op me1 me2 => mdenote me1 + mdenote me2
    end.

We will normalize expressions by flattening them into lists, via associativity, so it is helpful to have a denotation function for lists of monoid values.

  Fixpoint mldenote (ls : list A) : A :=
    match ls with
    | nil => e
    | x :: ls' => x + mldenote ls'
    end.

The flattening function itself is easy to implement.

  Fixpoint flatten (me : mexp) : list A :=
    match me with
    | Ident => []
    | Var x => [x]
    | Op me1 me2 => flatten me1 ++ flatten me2
    end.

This function has a straightforward correctness proof in terms of our denote functions.

  
A: Set
e: A
f: A -> A -> A
assoc: forall a b c : A, a + b + c = a + (b + c)
identl: forall a : A, e + a = a
identr: forall a : A, a + e = a

forall ml2 ml1 : list A, mldenote (ml1 ++ ml2) = mldenote ml1 + mldenote ml2
A: Set
e: A
f: A -> A -> A
assoc: forall a b c : A, a + b + c = a + (b + c)
identl: forall a : A, e + a = a
identr: forall a : A, a + e = a

forall ml2 ml1 : list A, mldenote (ml1 ++ ml2) = mldenote ml1 + mldenote ml2
induction ml1; simpl; congruence. Qed. Hint Rewrite flatten_correct'.
A: Set
e: A
f: A -> A -> A
assoc: forall a b c : A, a + b + c = a + (b + c)
identl: forall a : A, e + a = a
identr: forall a : A, a + e = a

forall me : mexp, mdenote me = mldenote (flatten me)
A: Set
e: A
f: A -> A -> A
assoc: forall a b c : A, a + b + c = a + (b + c)
identl: forall a : A, e + a = a
identr: forall a : A, a + e = a

forall me : mexp, mdenote me = mldenote (flatten me)
induction me; simpl; autorewrite with core; intuition congruence. Qed.

Now it is easy to prove a theorem that will be the main tool behind our simplification tactic.

  
A: Set
e: A
f: A -> A -> A
assoc: forall a b c : A, a + b + c = a + (b + c)
identl: forall a : A, e + a = a
identr: forall a : A, a + e = a

forall me1 me2 : mexp, mldenote (flatten me1) = mldenote (flatten me2) -> mdenote me1 = mdenote me2
A: Set
e: A
f: A -> A -> A
assoc: forall a b c : A, a + b + c = a + (b + c)
identl: forall a : A, e + a = a
identr: forall a : A, a + e = a

forall me1 me2 : mexp, mldenote (flatten me1) = mldenote (flatten me2) -> mdenote me1 = mdenote me2
intros; repeat rewrite flatten_correct; assumption. Qed.

We implement reification into the mexp type.

  Ltac reify me :=
    match me with
    | e => Ident
    | ?me1 + ?me2 =>
        let r1 := reify me1 in
        let r2 := reify me2 in
        constr:(Op r1 r2)
    | _ => constr:(Var me)
    end.

The final monoid tactic works on goals that equate two monoid terms. We reify each and change the goal to refer to the reified versions, finishing off by applying monoid_reflect and simplifying uses of mldenote.

  Ltac monoid :=
    match goal with
    | [ |- ?me1 = ?me2 ] =>
        let r1 := reify me1 in
        let r2 := reify me2 in
          change (mdenote r1 = mdenote r2);
          apply monoid_reflect; simpl
    end.

We can make short work of theorems like this one:

  
A: Set
e: A
f: A -> A -> A
assoc: forall a b c : A, a + b + c = a + (b + c)
identl: forall a : A, e + a = a
identr: forall a : A, a + e = a

forall a b c d : A, a + b + c + e + d = a + (b + c) + d
A: Set
e: A
f: A -> A -> A
assoc: forall a b c : A, a + b + c = a + (b + c)
identl: forall a : A, e + a = a
identr: forall a : A, a + e = a

forall a b c d : A, a + b + c + e + d = a + (b + c) + d
A: Set
e: A
f: A -> A -> A
assoc: forall a b c : A, a + b + c = a + (b + c)
identl: forall a : A, e + a = a
identr: forall a : A, a + e = a
a, b, c, d: A

a + (b + (c + (d + e))) = a + (b + (c + (d + e)))

Our tactic has canonicalized both sides of the equality, such that we can finish the proof by reflexivity.

    reflexivity.
  Qed.

It is interesting to look at the form of the proof.

  Set Printing All.
  
t1 = fun a b c d : A => monoid_reflect (Op (Op (Op (Op (Var a) (Var b)) (Var c)) Ident) (Var d)) (Op (Op (Var a) (Op (Var b) (Var c))) (Var d)) (@eq_refl A (f a (f b (f c (f d e)))) : @eq A (mldenote (flatten (Op (Op (Op (Op (Var a) (Var b)) (Var c)) Ident) (Var d)))) (mldenote (flatten (Op (Op (Var a) (Op (Var b) (Var c))) (Var d))))) : forall a b c d : A, @eq A (f (f (f (f a b) c) e) d) (f (f a (f b c)) d) Arguments t1 a b c d t1 uses section variables A e f assoc identl identr.
Unset Printing All.

The proof term contains only restatements of the equality operands in reified form, followed by a use of reflexivity on the shared canonical form.

End monoid.

Extensions of this basic approach are used in the implementations of the ring and field tactics that come packaged with Coq.