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.
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 8repeat constructor. Qed. Set Printing All.isEven 8Unset Printing All.isEven 256repeat constructor. Qed. Set Printing All.isEven 256Unset 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:
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 nisEven 0n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
H: 1 < S n~ isEven 1n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
n0: nat
H: S (S n0) < S nif check_even n0 then isEven (S (S n0)) else ~ isEven (S (S n0))constructor.n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
H: 0 < S nisEven 0inversion 1.n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
H: 1 < S n~ isEven 1n: nat
IHn: forall n' : nat, n' < n -> if check_even n' then isEven n' else ~ isEven n'
n0: nat
H: S (S n0) < S nif check_even n0 then isEven (S (S n0)) else ~ isEven (S (S n0))destruct check_even; (constructor || inversion 1); apply IHn; auto; lia. Qed.n, n0: nat
IHn: n0 < n -> if check_even n0 then isEven n0 else ~ isEven n0
H: S (S n0) < S nif check_even n0 then isEven (S (S n0)) else ~ isEven (S (S n0))check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n
n: natif check_even n then isEven n else ~ isEven ncheck_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n
n: natif check_even n then isEven n else ~ isEven ncheck_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven nisEven 0check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n~ isEven 1check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n
n: natif check_even n then isEven (S (S n)) else ~ isEven (S (S n))constructor.check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven nisEven 0inversion 1.check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n~ isEven 1check_even_ok'': forall n : nat, if check_even n then isEven n else ~ isEven n
n: natif 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 nif check_even n then isEven (S (S n)) else ~ isEven (S (S n))n: nat
check_even_ok'': isEven nisEven (S (S n))n: nat
check_even_ok'': ~ isEven n~ isEven (S (S n))constructor; eauto.n: nat
check_even_ok'': isEven nisEven (S (S n))inversion 1; eauto. Qed.n: nat
check_even_ok'': ~ isEven n~ isEven (S (S n))forall n : nat, check_even n = true -> isEven nforall n : nat, check_even n = true -> isEven nn: nat
H: check_even n = trueisEven nn: nat
H: check_even n = true
H0: if check_even n then isEven n else ~ isEven nisEven nassumption. Qed.n: nat
H: check_even n = true
H0: isEven nisEven n
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 256prove_even_reflective. Qed. Set Printing All.isEven 256Unset 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 255isEven 255Abort.isEven 255
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.
As a next example, let's see how to get reflective proofs of trivial tautologies.
True /\ True -> True \/ True /\ (True -> True)tauto. Qed.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 tinduction t; simpl; auto. Qed.forall t : taut, tautDenote t
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)obvious. Qed. Set Printing All.True /\ True -> True \/ True /\ (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.
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 = aforall ml2 ml1 : list A, mldenote (ml1 ++ ml2) = mldenote ml1 + mldenote ml2induction 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 = aforall ml2 ml1 : list A, mldenote (ml1 ++ ml2) = mldenote ml1 + mldenote ml2A: 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 = aforall me : mexp, mdenote me = mldenote (flatten me)induction me; simpl; autorewrite with core; intuition congruence. Qed.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 = aforall me : mexp, mdenote me = mldenote (flatten me)
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 = aforall me1 me2 : mexp, mldenote (flatten me1) = mldenote (flatten me2) -> mdenote me1 = mdenote me2intros; repeat rewrite flatten_correct; assumption. Qed.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 = aforall me1 me2 : mexp, mldenote (flatten me1) = mldenote (flatten me2) -> mdenote me1 = mdenote me2
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 = aforall a b c d : A, a + b + c + e + d = a + (b + c) + dA: 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 = aforall a b c d : A, a + b + c + e + d = a + (b + c) + dA: 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: Aa + (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.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.