// Let's step back and try to understand better how Dafny processes proofs, so // we can better predict what will be accepted. This section of the code is // heavily inspired by Amazon's training materials at: // https://dafny.org/teaching-material/Lectures/2-4-Logic-ProvingByConvincing.html // First, a review from the associated reading and the previous class: // Consider this lemma statement. lemma Example(x: int) requires x % 2 == 0 ensures 2 * (x / 2) == x // We can explain what we are asking Dafny to prove using a notation for *goals* // inspired by the literature on automated computer theorem proving. // x: int, x % 2 == 0 |- 2 * (x / 2) == x // The "|-" is a "turnstile," which separates assumptions (lefthand side) from // conclusions (righthand side). Assumptions can include variables like `x` // with their types, as well as logical facts, and we separate the parts with // commas. The goal can be read as a "proof challenge": prove the conclusion, // assuming only the given assumptions. // How can we predict which proof challenges Dafny can meet? A fully precise // answer is very difficult to give, but we will survey some simple rules that // are safe to assume Dafny will apply automatically. // The following goes through automatically, despite being a bit of a mouthful. lemma UseAssumption(x1: T1, x2: int, x3: int -> T4, P: bool, Q: T1 -> bool) requires Q(x1) requires P ensures P { } // Intuitively, we expect that proof is easy, because we assume what we want to // prove! We can use our goal notation to explain what rule we want Dafny to use. // I'll write 'G' in place of the Greek letter capital Gamma that is more commonly // used in the literature. // G, P |- P // Assumptions are fundamentally unordered, so the notation above is just for // picking some known fact `P` out of the assumptions. If we are trying to // prove that fact, we are done. // Every logical connective has its associated rules that explain how to work // with it. We need to know how to *prove* a connective, with what are called // *introduction* rules; and we need to know how to *use* a connective to prove // something else, and these are called *elimination* rules. (Don't worry about // that terminology for purposes of this class; we just mention the words for // those curious to read more outside our scope.) // The easiest example is "and". // How to prove it: // G, P, Q |- P && Q // In other words, if you know `P` and `Q`, you know `P && Q`. // We kind of just switched out one kind of "and" for another! // How to use it: // G, P && Q |- P // G, P && Q |- Q // In other words, if you know `P && Q`, you also know either operand. // Each of these rules can be embodied as a fully general Dafny lemma. We'll go // through the exercise for "and" but not bother for most later examples. lemma ProveAnd(P: bool, Q: bool) requires P requires Q ensures P && Q { } lemma UseAnd1(P: bool, Q: bool) requires P && Q ensures P { } lemma UseAnd2(P: bool, Q: bool) requires P && Q ensures Q { } // We could invoke these lemmas from other proofs, but it isn't worth the // effort, because Dafny already applies these rules automatically. // Let's take a look at "or". // How to prove it: // G, P |- P || Q // G, Q |- P || Q // How to use it: // G, P || Q, P ==> R, Q ==> R ⊢ R // That last rule is interesting for using the implication connective `==>`, // too. The rule is basically formalizing the idea of a proof by cases. At // least one of `P` or `Q` must be true, and we check that our goal is provable // under either new assumption. // Translating the above into lemmas: lemma ProveOr1(P: bool, Q: bool) requires P ensures P || Q { } lemma ProveOr2(P: bool, Q: bool) requires Q ensures P || Q { } lemma UseOr(P: bool, Q: bool, R: bool) requires P || Q requires P ==> R requires Q ==> R ensures R { } // But then how do we prove implications? The rule says that if we are looking // at proving: // G |- P ==> Q // we can switch to proving instead: // G, P |- Q // The new premise moves into our assumptions. // And using an implication is as simple as the classic modus ponens rule: // G, P ==> Q, P |- Q // We can think of negation as implication of falsehood, giving rise to two rules. // G, P ==> false |- !P // G, P, !P |- false // And then we can explain falsehood itself, which notably has no rule to prove it. // G, false |- P // That is, when we know `false`, we can conclude anything, because the situation // is inherently contradictory. // We can think of `P <==> Q` as expanding to `(P ==> Q) && (Q ==> P)`, which // implies the following rules. // G, P ==> Q, Q ==> P |- P <==> Q // G, P <==> Q |- P ==> Q // G, P <==> Q |- Q ==> P // Quantifiers are more interesting still. Here's `exists`. // G, k: T, P(k) |- exists c: T :: P(c) // In other words, to prove an existential, we should find a known fact that // matches the body of the quantifier, with a specific witness plugged in. // To use an `exists`: // G, (exists x: T :: P(x)), (forall x: T :: P(x) ==> Q) ⊢ Q // The rule lefts us switch to thinking in terms of all potential witnesses for // the quantifier, via a `forall`, which tends to be easier to work with. // But how *do* we work with `forall`? The easiest is using one. // G, (forall x: T :: P(x)), c: T |- P(c) // Any value of the right type yields a true fact, when plugged into the // quantifier. // How do we prove a `forall`? This one we have to express like for // implication, as a rule for transforming a goal into a simpler one. // To prove: // G |- forall x: T :: P(x) // We can switch to proving: // G, x : T |- P(x) // Note how the quantified variable became part of the proof goal, like any // other variable that might e.g. be a lemma parameter. // With those rules stated, let's see how Dafny produces proof challenges to // tackle, given the structure of a lemma or other code. type T predicate P(x: T) predicate Q(x: T) predicate R(x: T) lemma Example2(x: T) requires P(x) ensures Q(x) { // We start from this goal, read off from the lemma statement. // x: T, P(x) |- Q(x) // Then whatever follows is supposed to *transform* the goal into one that is // easily enough proved using the rules we gave above, e.g. a simple case // where we managed to get `Q(x)` as an assumption. // x: T, P(x), Q(x) |- Q(x) assume false; // This marker included just to avoid Dafny complaining. // We know the proof isn't finished! } // Let's see how different programming constructs tweak proof goals. lemma AssertExample(x: T) requires P(x) ensures Q(x) { // x: T, P(x) |- Q(x) assert R(x) by { // x: T, P(x) |- R(x) assume false; } // We get the conclusion of the `assert` added as a new assumption. // x: T, P(x), R(x) |- Q(x) assume false; } lemma AssumeExample(x: T) requires P(x) ensures Q(x) { // x: T, P(x) |- Q(x) assume R(x); // T, x: T, P(x), R(x) |- Q(x) // We unceremoniously added a new assumption without proving it. assume false; } lemma Premise(x: T) requires P(x) ensures R(x) lemma HelperExample(x: T) requires P(x) ensures Q(x) { // x: T, P(x) |- Q(x) Premise(x); // x: T, P(x) |- P(x) [proving the precondition] // T, x: T, P(x), R(x) |- Q(x) // We added the postcondition of `Premise` as a new assumption. assume false; } // Note how the following is subtly different, for using logical implication // instead of a precondition. lemma Premise2(x: T) ensures P(x) ==> R(x) lemma HelperExample2(x: T) requires P(x) ensures Q(x) { // x: T, P(x) |- Q(x) Premise2(x); // x: T, P(x), P(x) ==> R(x) |- Q(x) assume false; } // We can go even further and use `forall` instead of a lemma parameter. lemma Premise3() ensures forall x: T :: P(x) ==> R(x) lemma HelperExample3(x: T) requires P(x) ensures Q(x) { // x: T, P(x) |- Q(x) Premise3(); // x: T, P(x), forall x: T :: P(x) ==> R(x) |- Q(x) assume false; } lemma IfExample(x: T) requires P(x) ensures Q(x) { // x: T, P(x) |- Q(x) if R(x) { // x: T, P(x), R(x) |- S(x) // The `if` expression is added as an assumption. assume false; } // x: T, P(x), R(x) ==> S(x) |- Q(x) // Note how the goal of the `if` is wrapped in an implication, to produce what // we remember afterward. assume false; } lemma IfExample2(x: T) ensures P(x) ==> Q(x) { // x: T |- P(x) ==> Q(x) if P(x) { Premise(x); // x: T, P(x) |- P(x) [proving precondition] // x: T, P(x), Q(x) |- R(x) // ^-- added postcondition, and we arbitrarily chose to prove `R(x)`. } // x: T, P(x) ==> R(x) |- P(x) ==> Q(x) assume false; } lemma ForallExample(x: T) requires P(x) ensures Q(x) { // x: T, P(x) |- Q(x) forall x: T ensures R(x) { // x: T, P(x) |- R(x) // Note how (1) the variable was added to the assumptions, and (2) the // `forall` body became our conclusion. assume false; } // T, x: T, P(x), forall x: T :: R(x) |- Q(x) // Then we get the expected `forall` as a new assumption. assume false; } predicate A() predicate B() predicate C() lemma ExistsExample() requires exists y: T :: P(y) ensures A() { // exists x: T :: P(x) |- A() var y: T :| P(y); // y: T, P(y) |- A() assume false; } lemma AssignmentExample(x: T) ensures A() { // x: T |- A() var y: T := x; // x: T, y: T, y == x |- A() assume false; } // Here is one quite mechanical approach to deriving a proof step-by-step: // assume what you want to prove, and keep replacing `assume` statements with // more specific steps that may themselves contain further `assume`s (at first). // Here are some general rules for replacing `assume`s with more specific code. // Can change: // assume A && B; // into: // assert A && B by { // assume A; // assume B; // } // Can change: // assume A || B; // into: // assert A || B by { // assume A; // } // or: // assert A || B by { // assume B; // } // or: // assert A || B by { // if !A { // assume B; // } // } // or: // assert A || B by { // if !B { // assume A; // } // } // Note that it is important to choose the right one of these rules, based on an // understanding of what will be provable later. // Can change: // assume A ==> B; // into: // assert A ==> B by { // if A { // assume B; // } // } // or (contrapositive): // assert A ==> B by { // if !B { // assume !A; // } // } // Can change: // assume !A; // into: // assert !A by { // if A { // assume false; // } // } // Can change: // assume forall x: nat :: P(x); // into: // assert forall x: nat :: P(x) by { // forall x: nat // ensures P(x) // { // assume P(x); // } // } // Can change: // assume exists x: nat :: P(x); // into: // assert exists x: nat :: P(x) by { // assume P(c); // } // Note that, like with "or," we have to make a choice (of the right `c`), and // the wrong choice can take us to an unprovable new goal. // Often we can't prove a fact by the above rules. Instead, we need to use some // of our hypotheses (appearing to the left of `|-` in goals) to make progress. // Here are some schematic examples of using hypotheses. Again, it is a // deliberate, strategic choice to pick a hypothesis to exploit. lemma UseImplication() requires A() ==> B() ensures B() { // A() ==> B() |- B() assume A(); // A() ==> B(), A() |- B() } lemma UseDisjunction() requires A() || B() ensures C() { // A() || B() |- C() if A() { assume C(); } // A() || B(), A() ==> C() |- C() if B() { assume C(); } // A() || B(), A() ==> C(), B() ==> C() |- C() } lemma UseNegation() requires !A() ensures false { // !A() |- false assume A(); // !A(), A() |- false } lemma UseExistential() requires exists c: T :: P(c) ensures C() { // exists c: T :: P(c) |- C() assume forall x: T :: P(x) ==> C(); // exists c: T :: P(c), forall x: T :: P(x) ==> C() |- C() var c: T :| P(c); // exists c: T :: P(c), c:T, P(c), forall x: T :: P(x) ==> C() |- C() } // This proof can be produced starting from `assume`ing the lemma statement and // gradually refining using the rules above (we often don't bother to write the // `assert`s from above, which were mostly included for documentation value). lemma s1() ensures forall x: nat :: x % 2 == 0 ==> exists y: nat :: x == 2 * y { // |- GOAL forall x: nat ensures x % 2 == 0 ==> exists y: nat :: x == 2 * y { // x: nat |- x % 2 == 0 ==> exists y: nat :: x == 2 * y if x % 2 == 0 { // x: nat, x % 2 == 0 |- exists y: nat :: x == 2 * y assert exists y: nat :: x == 2 * y by { // x: nat, x % 2 == 0 |- exists y: nat :: x == 2 * y assert x == 2 * (x / 2); // x: nat, x % 2 == 0, x == 2 * (x / 2) |- exists y: nat :: x == 2 * y } // x: nat, x % 2 == 0, exists y: nat :: x == 2 * y |- exists y: nat :: x == 2 * y } // x: nat, x % 2 == 0 ==> exists y: nat :: x == 2 * y // |- x % 2 == 0 ==> exists y: nat :: x == 2 * y } // GOAL |- GOAL } // Let's practice these constructions in the context of a very handy feature // that Dafny has in common with Python: set comprehensions. function Interval(n: nat, m: nat): set { set k | n <= k < m } lemma SizeOfInterval(m: nat, n: nat) requires n <= m ensures |Interval(n, m)| == m - n { if m == n { } else { assert m-1 in Interval(n, m); assert |Interval(n, m)| == 1 + |Interval(n, m) - {m-1}|; assert Interval(n, m) - {m-1} == Interval(n, m-1); assert |Interval(n, m)| == 1 + |Interval(n, m-1)|; SizeOfInterval(m-1, n); } } lemma JoinIntervals(m: nat, n: nat, o: nat) requires m <= n <= o ensures Interval(m, o) == Interval(m, n) + Interval(n, o) { } // The following variant of set comprehensions allows us to form the final set // elements by applying a function to the variables we quantify over. function SetMap(f: A -> B, s: set): set { set x | x in s :: f(x) } lemma SizeSetMap(f: A -> B, s: set) ensures |SetMap(f, s)| <= |s| { if |s| == 0 { } else { var x :| x in s; assert SetMap(f, s) == SetMap(f, s - {x}) + {f(x)}; SizeSetMap(f, s - {x}); } } // We apologize for the mysterious `(!new)` below, which basically says "this // type is sufficiently simple and pure," not using object-oriented features. // More explanation to come when we get to classes in the final month or so. ghost predicate Injective(f: A -> B) { forall x, y :: f(x) == f(y) ==> x == y } lemma SizeSetMapInjective(f: A -> B, s: set) requires Injective(f) ensures |SetMap(f, s)| == |s| { if |s| == 0 { } else { var x :| x in s; assert SetMap(f, s) == SetMap(f, s - {x}) + {f(x)}; SizeSetMapInjective(f, s - {x}); } } // OK, that's pretty cool that the proof using injectivity goes through with so // little modification. // This next example worked well for us with gradually expanding the proof // structure and seeing where Dafny was stuck at each stage. lemma ShiftInterval(a: nat, b: nat, k: nat) ensures SetMap((n) => n + k, Interval(a, b)) == Interval(a + k, b + k) { forall x ensures x in SetMap((n) => n + k, Interval(a, b)) <==> x in Interval(a + k, b + k) { if x in SetMap((n) => n + k, Interval(a, b)) { assert x in Interval(a + k, b + k); } if x in Interval(a + k, b + k) { assert a + k <= x < b + k; assert a <= x - k < b; assert x - k in Interval(a, b); // In fact, this line by itself within this 'if' suffices to convince Dafny, // but the other lines help explain how it fits into the proof structure. assert x - k + k in SetMap((n) => n + k, Interval(a, b)); assert x - k + k == x; assert x in SetMap((n) => n + k, Interval(a, b)); } } } function SetFilter(f: A -> bool, s: set): set { set x | x in s && f(x) } lemma FilterInterval(a: nat, b: nat, k: nat) requires a <= k <= b ensures SetFilter((n) => n >= k, Interval(a, b)) == Interval(k, b) { } // OK, let's get into a fancier example. It adds two maps together, combining // the values of shared keys using addition. function AddMaps(a: map, b: map): map { (map x | x in a && x in b :: a[x] + b[x]) + (map x | x in a && x !in b :: a[x]) + (map x | x in b && x !in a :: b[x]) } // This lemma copied from the previous class. lemma SetHasMinimum(s: set) requires |s| != 0 ensures exists n :: n in s && forall m :: m in s ==> n <= m { var n :| n in s; if |s - {n}| == 0 { } else { SetHasMinimum(s - {n}); assert forall m :: m in s ==> m == n || m in s - {n}; } } // We can also sum up all entries of a map. function MapSum(a: map): int { if |a| == 0 then 0 else SetHasMinimum(a.Keys); var n :| n in a.Keys && forall m :: m in a.Keys ==> n <= m; a[n] + MapSum(a - {n}) } // So how do those last two functions interact? lemma AddMapsSum(a: map, b: map) ensures MapSum(AddMaps(a, b)) == MapSum(a) + MapSum(b) { if |a| == 0 && |b| == 0 { } else { AddMapsKeys(a, b); var n := MapSumNext(AddMaps(a, b)); calc { MapSum(AddMaps(a, b)); == AddMaps(a, b)[n] + MapSum(AddMaps(a, b) - {n}); == { AddMapsRemove(a, b, n); } AddMaps(a, b)[n] + MapSum(AddMaps(a - {n}, b - {n})); == { AddMapsSum(a - {n}, b - {n}); } AddMaps(a, b)[n] + MapSum(a - {n}) + MapSum(b - {n}); } MapSumIsolate(a, n); MapSumIsolate(b, n); } } // This pattern is handy for letting us quickly grab ahold of the first key that // MapSum will process. Note again use of a lemma *with a return value*. lemma MapSumNext(a: map) returns (n: int) requires |a| != 0 ensures n in a ensures forall m :: m in a.Keys ==> n <= m ensures MapSum(a) == a[n] + MapSum(a - {n}) { SetHasMinimum(a.Keys); var n' :| n' in a.Keys && forall m :: m in a.Keys ==> n' <= m; n := n'; } // This simple fact is easy for Dafny to prove, even though Dafny doesn't think // to use it within larger proofs unless we suggest it explicitly. lemma AddMapsKeys(a: map, b: map) ensures AddMaps(a, b).Keys == a.Keys + b.Keys { } // This workhorse lemma explains how to pull any single term out of a MapSum. lemma MapSumRemove(a: map, n: int) requires n in a ensures MapSum(a) == a[n] + MapSum(a - {n}) { if |a| > 0 { var k := MapSumNext(a); if k != n { calc { MapSum(a); a[k] + MapSum(a - {k}); { MapSumRemove(a - {k}, n); } a[k] + a[n] + MapSum(a - {k} - {n}); { assert a - {n} - {k} == a - {k} - {n}; } a[k] + a[n] + MapSum(a - {n} - {k}); a[n] + MapSum(a - {n}); } } } } // Dually, there is no impact from pulling out a map key that was not there to // begin with. lemma MapSumNoRemove(a: map, n: int) requires n !in a ensures MapSum(a) == MapSum(a - {n}) { if |a| > 0 { var k := MapSumNext(a); calc { MapSum(a); a[k] + MapSum(a - {k}); { MapSumNoRemove(a - {k}, n); } a[k] + MapSum(a - {k} - {n}); { assert a - {n} - {k} == a - {k} - {n}; } a[k] + MapSum(a - {n} - {k}); MapSum(a - {n}); } } } // We can combine the above two lemmas into a general relationship between the // MapSum before and after removing an arbitrary key. lemma MapSumIsolate(a: map, n: int) ensures MapSum(a) == (if n in a then a[n] else 0) + MapSum(a - {n}) { if n in a { MapSumRemove(a, n); } else { MapSumNoRemove(a, n); } } // Here's the last lemma we used in the main proof above. lemma AddMapsRemove(a: map, b: map, n: int) ensures AddMaps(a, b) - {n} == AddMaps(a - {n}, b - {n}) { } // OK, that was quite a workout for practicing proof structuring! // Now let's add some quantifiers into the mix. // The following is basically classic function composition, specialized to maps. function MapCompose(f: map, g: map): map requires forall x :: x in f ==> f[x] in g { map x | x in f :: g[f[x]] } // Here are two standard function properties from discrete-math class. ghost predicate MapInjective(f: map) { forall x, y :: x in f && y in f && f[x] == f[y] ==> x == y } ghost predicate MapSurjective(f: map, s: set) { forall y :: y in s ==> exists x :: x in f && f[x] == y } // If we assume them, we can prove that the sum of a map remains the same when // we compose it with any other map that satisfies the above properties. lemma MapComposeSum(f: map, g: map) requires forall x :: x in f ==> f[x] in g requires MapInjective(f) requires MapSurjective(f, g.Keys) ensures MapSum(MapCompose(f, g)) == MapSum(g) { if |g| != 0 { var n := MapSumNext(g); var n' :| n' in f && f[n'] == n; // Let's check all the requirements for a recursive call (to "invoke the IH"). assert forall x :: x in f - {n'} ==> (f - {n'})[x] in g - {n}; assert MapInjective(f - {n'}); assert MapSurjective(f - {n'}, (g - {n}).Keys) by { MapSurjectiveRemove(f, g, n, n'); } // OK, only the last one wasn't proven automatically by Dafny, instead // relying on a lemma that appears below. calc { MapSum(MapCompose(f, g)); == { MapSumRemove(MapCompose(f, g), n'); } MapCompose(f, g)[n'] + MapSum(MapCompose(f, g) - {n'}); == { MapComposeRemove(f, g, n, n'); } g[n] + MapSum(MapCompose(f - {n'}, g - {n})); == { MapComposeSum(f - {n'}, g - {n}); } g[n] + MapSum(g - {n}); == MapSum(g); } } } lemma MapComposeRemove(f: map, g: map, n: int, n': int) requires n in g requires n' in f && f[n'] == n requires MapInjective(f) requires forall x :: x in f ==> f[x] in g ensures MapCompose(f - {n'}, g - {n}) == MapCompose(f, g) - {n'} { } lemma MapSurjectiveRemove(f: map, g: map, n: int, n': int) requires n in g requires n' in f && f[n'] == n requires MapInjective(f) requires MapSurjective(f, g.Keys) ensures MapSurjective(f - {n'}, (g - {n}).Keys) { forall y ensures y in (g - {n}).Keys ==> exists x :: x in f - {n'} && (f - {n'})[x] == y { if y in (g - {n}).Keys { var x :| x in f && f[x] == y; assert x in f - {n'}; } } }