Instructor: Jianan Yao
LEC: Th1:10-3:00PM MC252
assn*4 40%
project 50%
types of program synthesis
formal verification: use formal methods of math to verify a program satisfying its specs.
timeline of major verification tools and systems
Flashfill: excel feature to autofill cells based on examples.
Alan Turing AT
John McCarthy JM
Donald E. Knuth DEK
Edsger Dijstra // guess ED
Leslie B. Lamport
suffers from ambiguity (too few examples - if keep 1st cell only, then it may guess JMC).
eg. given input: (425)-706-7709, output: 425-706-7709. for simplicity, restrict the synthesized program to be a finite substring concat:
output = substring + constant + substring + ...
for "706" there are many possible expressions deriving it:
suppose there are 10 different ways to derive "425" in the output, 10 ways for "706" and 10 ways for "7709". there are 1000 candidate programs. use a compact set representation instead of enumerating all:
[1st number between 1st pair of "()": "425"], ...
"-"
[2nd number between 1st and 2nd "-": "706"], ...
"-"
[3rd number after 2nd "-": "7709"], ...
if there are more examples, the synthesized program must satisfy all. so we need to intersect the candidate sets for each part of the output.
this however assumes a linear structure (split by "-"). to improve we use a DAG representation.
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ 1st number between │ ┌─────┐ │ 2nd number between │ ┌─────┐ │ 3rd number │
start ●─┤ 1st pair of "()" ├─●─┤ "-" ├─●─┤ 1st and 2nd "-" ├─●─┤ "-" ├─●─┤ 1st number after ├─● end
│ "425" │ │ └─────┘ │ │ "706" │ └─────┘ │ last "-" "7709" │ │
│ ... │ │ │ │ ... │ │ ... │ │
└────────────────────┘ │ │ └────────────────────┘ └────────────────────┘ │
│ │ ┌────────────────────┐ │
│ │ │ after 1st "-" │ │
│ └─────────────┤ last 8 characters ├────────────────────────┤
│ │ "706-7709" │ │
│ │ ... │ │
│ └────────────────────┘ │
│ ┌────────────────────┐ │
└───────────────────────┤ after first ")" ├────────────────────────┘
│ ... │
└────────────────────┘
what if no single program works for every example? the intersections of DAGs can be empty.
(425)-706-7709 425-706-7709
604 910 2026 604-910-2026
978-5804 416-978-5804
946 8727 416-946-8727
FlashFill greedily groups examples that share a common program. then synthesize a classifier (e.g. boolean) to separate examples. The final output:
if input has 3 numbers
then SubStr(input, 1st number) + “-” + SubStr(input, 2nd number) + “-” + SubStr(input, 3rd number)
else “416-” + SubStr(input, 1st number) + “-” + SubStr(input, 2nd number)
correctness: starting from any state satisfying P (precondition), we want to ensure running this code lands in a state satisfying Q (postcondition).
pre- and postconditions divide responsibilities between caller and callee
| caller | callee | |
|---|---|---|
| precondition | must establish | may assume |
| postcondition | may assume | must establish |
consequences:
eg. specifying min
// pre: true
int min(int a, int b) {
if (a < b) r = a;
else r = b;
return r;
}
// post: r <= a && r <= b && (r == a || r == b)
central trade of formal verification: burden of being right moves from a large operational artifact (the code) to a small declarative one (the specification). specs are usually far shorter and far easier to review, but they are now the thing that must be right.
defn. (hoare triple) { P } C { Q }, where P is the precondition (an assertion), C is the program (command), and Q is the postcondition (an assertion).
notation.
⊨ {P} C {Q}: triple is true of the actual execution behaviour (semantics)⊢ {P} C {Q}: triple is derivable using the rules of hoare logic (syntax)hoare logic is sound: anything derivable is true. It is also complete relative to the assertion language (Cook, 1978) - if a triple is true, a derivation exists, provided we can prove the arithmetic facts it needs.
notation.
{P} C {Q}: partial correctness; if C terminates, Q holds[P] C [Q]: total correctness; C terminates, and Q holds
eg. with unbounded integers
{true} while (true) {} {1=2}: holds under partial correctness{x=y} ++x {x=y}: failure{false} x:=1 {x=2}: vacuously holdswe use one rule per construct.
defn. (skip axiom)
defn. (assignment axiom)
Q[e/x] means: Q with every free occurrence of x replaced by the expression e.
side conditions that matter in real language:
eg. we read assignment backwards. to guarantee Q after the assignment, require beforehand what Q says about x.
| postcond Q | command x:=e | precond P[e/x] |
|---|---|---|
x>0 |
x:=y+1 |
y+1>0 |
x=6 |
x:=x+1 |
x+1=6 i.e. x=5 |
y*y>=36 |
y:=x+5 |
(x+5)*(x+5) >= 36 |
x=y |
x:=x+1 |
x+1=y |
eg. the following forward rule is incorrect: {P} x:=e {P∧x=e}. take P as x=5 and command as x:=x+1. the post cond yields x=5 ∧ x=x+1, which is false.
there is a correct forward rule (sound, but introduces an existential quantifier):
defn. (sequence rule)
we may always strengthen the precondition and weaken the postcondition (conseq rule):
defn. (conditional rule)
eg. show .
steps 1-3 are mechanical, step 4 is the only genuine obligation (and it is not related to program).
eg. show , where .
observation:
plan:
defn. wp(C, Q) is the weakest assertion P such that {P} C {Q} holds, meaning:
{wp(C, Q) C {Q} holds and,{P'} C {Q}, we have P' ⇒ wp(C, Q).if our actual precondition P does not imply wp, non proof {P} C {Q} exists. there single check P ⇒ wp(C, Q) is therefore sound and complete.
note. under partial correctness, this is strictly the weakest liberal precondition (wlp), which does not require termination. for loop-free programs, wp=wlp.
| expression | defn. |
|---|---|
wp(skip, Q) |
Q |
wp(x:=e, Q) |
Q[e/x] |
wp(C₁;C₂, Q) |
wp(C₁, wp(C₂, Q)) |
wp(if b then C₁ else C₂, Q) |
(b ⇒ wp(C₁, Q)) ∧ (¬b ⇒ wp(C₂, Q)) |
wp(assert R, Q) |
R ∧ Q |
wp(assume R, Q) |
R ⇒ Q |
eg. Q={z≥36}, C={y=x+5; z=y*y}, find wp(C, Q).
// {(x + 5) * (x + 5) >= 36} <-- wp(C, Q)
y = x + 5;
// {y * y >= 36} <-- wp(z := y*y, Q)
z = y * y;
// {z >= 36} <-- Q
start from bottom with Q, and push it upwards one statement at a time. we end up at the top is a formula over program's input only.
caveat: by substitution, we may have duplicate large expressions. real tools avoid blow-up with SSA form and shared term representations.
defn. given spec (P, Q) and loop-free program C, the verification condition is a single formula: VC(P, C, Q) ≡ P ⇒ wp(C, Q).
VC(P,C,Q) holds, then {P} C {Q} holds.{P} C {Q} holds, then VC(P,C,Q) holds.for our example, it is x≥1 ⇒ (x+5)*(x+5)≥36.
verification condition generation:
the verification condition is the interface separating compilation and search.
eg. given , then
and VC ≡ true ⇒ formula above.
note if there are n conditionals, then there are 2^n paths to consider. VC has joined the implications so it grows linearly. case analysis is moved inside the solver.
defn. a formula is valid if it is true under every interpretation of its free variables. a formula is satisfiable if it is true under some interpretation.
x≥1 ⇒ (x+5)*(x+5)≥36 means for all x)given a VC, we ask whether ¬VC is satisfiable:
eg. if F = x≥1 ⇒ (x+5)²≥36, then ¬F = x≥1 ∧ (x+5)²<36. we feed this into solver.
SAT: given boolean formula F over variables x₁, x₂, ..., xₙ, find an assignment of true/false to those variables making F true, or prove that none exists
satisfiability modulo theories (SMT): given a first-order formula F in which some function and predicate symbols carry a fixed interpretation prescribed by a background theory T, decide whether F is satisfiable in T.
common theories:
| theory | example | decidable? | cost |
|---|---|---|---|
| equality with uninterpreted functions (EUF) | f(f(f(a))) = a ∧ f(f(f(f(f(a))))) = a ∧ f(a) ≠ a — unsat |
yes | polynomial - congruence closure |
| linear integer arithmetic (LIA) | 3x+2y≤7 ∧ x−y≥1 |
yes | NP-complete |
| nonlinear integer arithmetic (NIA) | x≥1 ∧ (x+5)²<36 |
no | undecidable - Hilbert's 10th problem |
| linear real arithmetic (LRA) | x/2+y≤3.5 ∧ x≥0 |
yes | polynomial; simplex in practice |
| nonlinear real arithmetic | x²+y²=4 |
yes (Tarski 1951) | doubly exponential - CAD |
| first-order logic with quantifiers | ∀x.∃y.f(x)=y |
no | undecidable - Church, Turing 1936 |
| fixed-width bit-vectors (BV) | x & (x − 1) = 0 ∧ x ≠ 0 (x is a power of two) |
yes | NP-complete - bit-blasted to SAT |
| arrays (with EUF) | select(store(A, i, v), j) |
yes | NP-complete |
| strings | concat(s, " world") = "hello world" |
undecidable means no procedure works for all input, but there may be that works for some.
how solvers work:
using Z3:
from z3 import *
x = Int('x')
pre = x >= 1
post = (x + 5) * (x + 5) >= 36 # this is wp(C, z >= 36)
s = Solver()
s.add(pre, Not(post)) # assert negation of VC
s.check() # unsat => correct
# or prove(Implies(pre, post))
# using SSA
x, y, z = Ints('x y z')
s = Solver()
s.add(x >= 1)
s.add(y == x + 5)
s.add(z == y * y)
s.add(Not(z >= 36))
s.check() # unsat => correct
# incorrect
s.add(z==y+y)
s.check() # sat => incorrect
s.model() # [y = 6, x = 1, z = 12]
# min function
a, b, r = Ints('a b r')
s = Solver()
s.add(r == If(a < b, a, b)) # program
s.add(Not(And(r <= a, r <= b, Or(r == a, r == b)))) # negated postcond
s.check() # unsat => correct
suppose we have a while loop while b do C, we can unwrap it once:
the math solution is its fixed point. we can also unwrap it k times: this is bounded model checking (bug-finding technique, not a proof). idea is to find an assertion every step.
defn. a loop invariant is an assertion I attached to the top to the loop.
{I} while b do C {I ∧ ¬b}defn. (while rule)
I is not part of program. proof writer has to find it.
proof obligations:
| obligation | prove | meaning |
|---|---|---|
| establishment | P ⇒ I |
I holds when loop enters |
| preservation | I ∧ b ⇒ wp(C, I) |
one iteration keeps I true |
| exit | I ∧ ¬b ⇒ Q |
I is strong enough to give the postcond |
eg. summing 0 to n-1
// pre: n >= 0
i = 0;
s = 0;
// inv: 0 <= i <= n
// && 2 * s == i * (i - 1)
while (i < n) {
s = s + i;
i = i + 1;
}
// post: 2 * s == n * (n - 1)
i <= n so that the loop exit is covered.
from z3 import *
i, s, n, i2, s2 = Ints('i s n i2 s2')
def Inv(i, s): return And(0 <= i, i <= n, 2*s == i*(i-1))
# establishment: n≥0 ⇒ I(i=0,s=0)
prove(Implies(n >= 0, Inv(0, 0)))
# preservation: I(i,s) ∧ i<n ⇒ I(i+1,s+i)
prove(Implies(And(Inv(i, s), i < n,
s2 == s + i, i2 == i + 1), Inv(i2, s2)))
# exit: I(i,s) ∧ ¬(i<n) ⇒ 2*s == n*(n-1)
prove(Implies(And(Inv(i, s), Not(i < n)), 2*s == n*(n-1)))
eg. finding maximal element in array
// pre: n >= 1
m = A[0];
i = 1;
// inv: 1 <= i <= n
// && (∀ k. 0 <= k < i ==> A[k] <= m)
// && (∃ k. 0 <= k < i && A[k] == m)
while (i < n) {
if (A[i] > m) m = A[i];
i = i + 1;
}
// post: (∀ k. 0 <= k < n ==> A[k] <= m)
// && (∃ k. 0 <= k < n && A[k] == m)
eg. invariant patterns
| postcondition | invariant I |
|---|---|
2*s = n(n-1) |
2*s = i*(i-1) ∧ 0≤i≤n |
∀k<n. A[k]≤m |
∀k<i. A[k]≤m ∧ 1≤i≤n |
r=n! |
r=i! ∧ 0≤i≤n |
A[0..n) is sorted |
A[0..i) is sorted ∧ 1≤i≤n |
¬(i<n) gives i≥n, plus I we get i=n - which collapses the invariant to the postcondition.invariant vs. inductive variant
{I ∧ b ⇒ wp(C, I)} is valid on its own: from any sate satisfying I ∧ b, executing C will result in a state satisfying I.defn. a counterexample to induction (CTI) is a state satisfying I ∧ b but its successor violates I.
eg. true but not inductive
// pre: True
x = 0;
// inv: x <= 10
while (x < 10) {
x = x + 2;
}
// post: x == 10
the invariant is true, but setting x=9 yields false (we have x≤10 ∧ x<10 !⇒ x+2≤10), although x=9 never happens. strengthening it to x≤10 ∧ x%2=0 can go through.
the strengthen loop algo:
eg. which are inductive invariants?
// pre: true
x = 0;
y = 0;
while (x < 10) {
x = x + 1;
y = y + 2;
}
// post: y == 20
check establish, preserve, and postcond
x≤10 - inductive, but week: final run x≥10 ∧ x≤10 says nothing about yy=2x - inductive, but weak: nothing pins x to 10 on exitx≤9 - not an invariant: x=10 at the final testy=2x ∧ x≤10 - inductive and strong enough. ← answery=20 - not established: y=0 on entry. too strongx=10 ⇒ y=20 - true at every visit, but not inductive. CTI: x=9, y=17when a check fails: three diagnoses
| the check that fails | usual cause | what to do |
|---|---|---|
establishment P ⇒ I |
I is too strong | weaken I, or strengthen the precondition |
preservation I ∧ b ⇒ wp(C, I) |
I is too weak — or the body really is wrong | read the CTI and decide which |
exit I ∧ ¬b ⇒ Q |
I is too weak for Q | add what the postcondition needs |
only when a preservation check fails with a reachable CTI can it indicate a bug in the program.
eg. nested loops: each loop needs its own invariant.
// inv_outer: A[0..i) is sorted && 0<=i<=n
while (i < n) {
j = i;
// inv_inner: ... && 0<=j<=i
while (j > 0 && A[j-1] > A[j]) {
swap(A, j-1, j);
j = j - 1;
}
i = i + 1;
}
finding invariant automatically:
eg. a partially correct program
// pre: n >= 0
i = 0;
// inv: 0 <= i
while (i != n) {
i = i + 2;
}
// post: i == n
0≤0=i0≤i ⇒ 0≤i+2¬(i≠n) ⇒ i=n
when it terminates, it is correct. however for odd n the program does not stop.defn. a ranking function (variant) for a loop is an integer expression V over the program such that
I ∧ b ⇒ V≥0 (it is bounded below) and{I ∧ b ∧ V=v₀} C {V<v₀} (every iteration decreases it) where v₀ is value of V at start of iterationtogether they force termination. ℕ is common choice but anything without infinite descending chains works (e.g. tuples, structural orders).
defn. (while-total rule)
called invariant and decreases clauses in Dafny and Verus.
eg. ranking functions
| loop | ranking function |
|---|---|
while (i<n) { i=i+1; } |
n-i |
while (x>0) { x=x-3; } |
x |
while (lo<hi) { ... lo=mid+1; ... hi=mid; } |
hi-lo |
while (x>0 && y>0) { if(...) x--; else y--; } |
x+y |
while (i!=n) { i=i+2; } |
none - diverges for odd n |
while (n!=1) { n = even(n)?n/2:3*n+1; } |
unknown - Collatz conjecture |
eg. lexicographic ranking function
// pre: x >= 0, y >= 0, N >= 0
while (x > 0) {
if (y > 0) {
y = y - 1;
} else {
x = x - 1;
y = N;
}
}
(x,y) ∈ ℕ×ℕ ordered lexicographicallydecreases x, y in Dafny/Verus. also the natural measure for nested loops and recursive functionstermination in practice:
the complete picture:
program C ─┐
spec P,Q ─┼─► VC generator ─► verif. conditions ─► SMT solver ─► verified/counterexample/unknown
inv+ranking ─┘
what can go wrong:
int assumed unbounded, but real machines wrap (32-bit). overflow, aliasing, floats, UB need explicit modellingunknown/timeouts are routine; performance can shift sharply on small changesverus is SMT-based verifier based on Z3 solver as backend.
why rust? ownership simplifies proof:
fn set_both(p: &mut u32, q: &mut u32)
ensures
*final(p) == 1,
*final(q) == 2,
{
// p and q must be different
*p = 1;
*q = 2;
}
vstd::prelude::*;
verus! {
fn min(a: u64, b: u64) -> (r: u64)
ensures
r <= a && r <= b,
r == a || r == b
{
if a < b { a } else { b }
}
} // verus!
two paths for program:
┍ erase ghost code ──► rustc ──► exec
│ (spec, pf, ...)
code ━┥
│ mode check ──► verification cond. ──► Z3
┕ VIR->AIR SMT-LIB sat?
| mode | written as | compiled? | checked? | used for |
|---|---|---|---|---|
| exec | fn |
yes | no | program |
| proof | proof fn, proof {} |
no | yes | lemmas and pf steps |
| spec | spec fn |
no | yes | math definitions and specs |
ghost code may use math types: int, nat (unbounded), Seq, Set, Map. they have no machine repr and exist only for verification.
specs:
| spec | Verus |
|---|---|
{P} C {Q} |
fn f(...) -> (r: T) requires P ensures Q { C } |
P ⇒ Q |
P ==> Q |
∀ i. P(i) |
forall|i: int| P(i) |
∃ i. P(i) |
exists|i: int| P(i) |
loop invariant I, ranking function V |
while b invariant I decreases V { ... } |
| an assertion at some program point | assert(P); |
spec is mathematical:
fn avg(a: u32, b: u32) -> (r: u32)
ensures r == (a + b) / 2,
{
// a / 2 + b / 2 + (a % 2 + b % 2) / 2
(a + b) / 2
}
// error: may overflow
caller sees only the contract:
fn min(a: u64, b: u64) -> (r: u64)
ensures r <= a && r <= b,
{
if a < b { a } else { b }
}
fn caller() {
let m = min(3, 7);
assert(m <= 3); // verified
assert(m == 3); // fails (need to add another ensures)
}
spec functions:
spec fn is_even(x: int) -> bool {
x % 2 == 0
}
// 1 + 2 + ... + n
spec fn triangle(n: nat) -> nat
decreases n,
{
if n == 0 { 0 }
else { n + triangle((n - 1) as nat) }
}
mutable states:
struct Counter { count: u64, limit: u64 }
impl Counter {
spec fn wf(&self) -> bool {
self.count <= self.limit
}
fn inc(&mut self)
requires
old(self).wf(),
old(self).count < old(self).limit,
ensures
final(self).wf(),
final(self).count == old(self).count + 1,
final(self).limit == old(self).limit,
{
self.count = self.count + 1;
}
}
wf (well-formed): invariant on data; every method may assume it and must reestablish itold(self): when method is calledfinal(self): when method returns. also written as self.loop:
fn ten() -> (x: u32)
ensures x == 10,
{
let mut x: u32 = 0;
while x < 10
invariant
x <= 10,
x % 2 == 0,
decreases 10 - x,
{
x = x + 2;
}
x
}
queries:
; function with loop cut
assume x == 0 ; P ⇒ I
assert x <= 10 ; before loop
assert x % 2 == 0
havoc x ; forget x ...
assume x <= 10 ; ... keep I
assume x % 2 == 0
assume !(x < 10) ; loop has exited
assert x == 10 ; postcondition
; one iteration of loop body
assume x <= 10 ; I holds
assume x % 2 == 0
assume x < 10 ; guard holds
assert 0 <= x + 2 < 2^32 ; overflow check
x := x + 2
assert x <= 10 ; I preserved
assert x % 2 == 0
assert decreased(10 - x) ; termination
inside loop body, verifier knows only the invariant and loop condition:
fn f(n: u64)
requires n > 5,
{
let x: u64 = 7;
let mut i: u64 = 0;
while i < 3
invariant i <= 3,
decreases 3 - i,
{
// assert(n > 5); // fails
// assert(x == 7); // fails
i = i + 1;
}
assert(n > 5); // verified
assert(i == 3); // verified
}
if body needs a fact, put in invariant.
Seq:
Vecv@: the Seq<T> that a Vec<T> representsVec v, v[i] in a spec means v@[i]assert(s1 =~= s2), verus knows s1 == s2with quantifier:
fn max(v: &Vec<u64>) -> (m: u64)
requires v.len() >= 1,
ensures
forall|k: int| 0 <= k < v.len() ==> v[k] <= m,
exists|k: int| 0 <= k < v.len() && v[k] == m,
{
let mut m = v[0];
let mut i: usize = 1;
while i < v.len()
invariant
1 <= i <= v.len(),
forall|k: int| 0 <= k < i ==> v[k] <= m,
exists|k: int| 0 <= k < i && v[k] == m,
decreases v.len() - i,
{
if v[i] > m { m = v[i]; }
i = i + 1;
}
m
}
why quantifiers are hard:
| where | what solver must do | difficulty |
|---|---|---|
| ∀ in the goal | prove P(c) for a fresh, arbitrary c | easy |
| ∃ in a hypothesis | give the witness a fresh name c, assume P(c) | easy |
| ∀ in a hypothesis | choose which instances P(t₁), P(t₂), ... to use | hard |
| ∃ in the goal | find a witness t with P(t) | hard |
proof fn some_zero(s: Seq<int>)
requires
s.len() > 0,
forall|i: int| 0 <= i < s.len() ==> s[i] == 0,
ensures
exists|k: int| 0 <= k < s.len() && s[k] == 0,
{
// will not verify unless this is added
assert(s[0] == 0);
}
0≤0<|s| ⇒ s[0]=0 is instantiated, other are notproof fn third_even(s: Seq<int>)
requires
s.len() == 10,
forall|i: int| 0 <= i < s.len()
==> #[trigger] is_even(s[i]),
{
assert(is_even(s[3])); // not verifying unless added
assert(s[3] % 2 == 0);
}
verus can choose triggers automatically:
==> is_even(#[trigger] s[i])
requires
s.len() == 10,
forall|i: int| 0 <= i < s.len() - 1
==> #[trigger] s[i] <= s[i + 1],
with this trigger, to show s[0] <= s[3], term s[0] gives an instance for i = 0, s[1] gives an instance for i = 1, and so on.
forall |x: int| #[trigger] f(x) < f(x + 1)
in principle it does not stop. Z3 cuts chains off with heuristics, so one can get failure, timeout, or pf that breaks after unrelated edit.
trigger rules:
assert(s[0] == 0))--log triggers) when quantifiers behave unexpectedlyis_sorted(s)) to control when they expandlemmas:
proof fn lemma_triangle_mono(i: nat, j: nat)
requires
i <= j,
ensures
triangle(i) <= triangle(j),
decreases j,
{
if i < j {
lemma_triangle_mono(i, (j - 1) as nat);
}
}
triangle(i) <= triangle(j - 1). one unfolding of triangle(j) adds j >= 0.induction as recursion:
| proof by induction | proof fn in Verus |
|---|---|
statement: for all n, P(n) |
proof fn lemma(n) ensures P(n) |
| base case | branch with no recursive call |
induction hypothesis P(n - 1) |
ensures of a recursive call lemma(n - 1) |
| inductive step | code around that recursive call |
| induction is well-founded | decreases n |
Calling a lemma on a smaller input assumes it holds for smaller inputs; decreases rules out circular reasoning.
lemma with sequences:
spec fn sum(s: Seq<u32>) -> int
decreases s.len(),
{
if s.len() == 0 { 0 }
else { sum(s.drop_last()) + s.last() }
}
proof fn lemma_sum_bound(s: Seq<u32>)
requires
forall|k: int| 0 <= k < s.len() ==> s[k] <= 1000,
ensures
sum(s) <= 1000 * s.len(),
decreases s.len(),
{
if s.len() > 0 {
lemma_sum_bound(s.drop_last());
}
}
fn total(v: &Vec<u32>) -> (t: u32)
requires
v.len() <= 1000,
forall|k: int| 0 <= k < v.len() ==> v[k] <= 1000,
ensures t == sum(v@),
{
let mut t: u32 = 0;
let mut i: usize = 0;
while i < v.len()
invariant
i <= v.len() <= 1000,
forall|k: int| 0 <= k < v.len() ==> v[k] <= 1000,
t == sum(v@.subrange(0, i as int)),
decreases v.len() - i,
{
proof {
lemma_sum_bound(v@.subrange(0, i as int)); // (1)
assert(v@.subrange(0, i + 1).drop_last()
=~= v@.subrange(0, i as int)); // (2)
}
t = t + v[i];
i = i + 1;
}
assert(v@.subrange(0, v.len() as int) =~= v@); // (3)
t
}
t <= 1000 * i < 2^32. without: possible overflowt = t + v[i], t == sum(new prefix) as desiredverus explicit escape hatches:
assume(P): take P without proofadmit(): give up on rest of proof#[verifier::external_body]: trust a func's body without checkingso far: synthesize from examples (lec 1), verify against logical specs (lec 2-4). today: synthesize from logical specs.
∀x. φ(C, x) valid? query on ¬φ(C, x)∀x. φ(f, x) is valid, i.e. ∃f. ∀x. φ(f, x), not just 'yes'
eg. max2: post r≥x ∧ r≥y ∧ (r=x ∨ r=y). many solutions: ite(x<=y, y, x), ite(y<=x, x, y); we need only one.
asking Z3 directly (f as uninterpreted function) fails:
f = Function('f', IntSort(), IntSort(), IntSort())
s.add(ForAll([x, y], And(f(x, y) >= x, f(x, y) >= y,
Or(f(x, y) == x, f(x, y) == y))))
s.check() # unknown, timeout after 1 min
even on success, it gives a Z3 model, not a program in a language we chose.
we can restrict the syntax with a grammar:
E ::= x | y | 0 | 1 | E+E | E-E | ite(B, E, E)
B ::= E <= E | E = E | B and B | not B
size = number of rules used to build it. ite(x<=y, y, x) has size 6.
why restrict syntax?
* in grammar says 'doable without multiplication'syntax-guided synthesis (SyGuS):
∀x. φ(e, x) is valid in T.eg. max2 in SyGuS input format:
(set-logic LIA)
; unknown function with grammar
(synth-fun max2 ((x Int) (y Int)) Int
((E Int) (B Bool))
((E Int (x y 0 1 (+ E E) (- E E) (ite B E E)))
(B Bool ((<= E E) (= E E) (and B B) (not B)))))
; inputs, implicitly ∀
(declare-var x Int)
(declare-var y Int)
; spec
(constraint (>= (max2 x y) x))
(constraint (>= (max2 x y) y))
(constraint (or (= (max2 x y) x) (= (max2 x y) y)))
(check-synth)
; (define-fun max2 ((x Int) (y Int)) Int (ite (<= x y) y x))
one format, many problems:
| kind | unknown is | example |
|---|---|---|
| integer arithmetic (LIA) | an integer function | max2 |
| bit-vectors (BV) | a bit-twiddling expression | average (lec 1) |
| programming by example (PBE) | a string transformation | FlashFill (lec 1) |
| invariants (INV) | a loop invariant | loops (lec 3) |
eg. BV average:
(set-logic BV)
; true average: add with one extra bit (33 bits), so nothing overflows
(define-fun spec ((a (_ BitVec 32)) (b (_ BitVec 32))) (_ BitVec 32)
((_ extract 31 0) (bvlshr (bvadd ((_ zero_extend 1) a) ((_ zero_extend 1) b))
(_ bv1 33))))
(synth-fun avg ((a (_ BitVec 32)) (b (_ BitVec 32))) (_ BitVec 32)
((E (_ BitVec 32)))
((E (_ BitVec 32) (a b (_ bv1 32)
(bvand E E) (bvor E E) (bvxor E E)
(bvadd E E) (bvsub E E) (bvlshr E E)))))
(declare-var a (_ BitVec 32))
(declare-var b (_ BitVec 32))
(constraint (= (avg a b) (spec a b)))
(check-synth)
; cvc5 (17s): (bvadd (bvand a b) (bvlshr (bvxor a b) #b0...01))
(_ bv1 33): 33-bit constant 1(_ extract 31 0): bits 31 through 0 (lower 32 bits)eg. FlashFill: constraints are just the examples.
(set-logic SLIA)
(synth-fun f ((s String)) String
((S String) (I Int))
((S String (s "-" (str.++ S S) (str.substr s I I)))
(I Int (0 1 3 (str.len s) (+ I I) (- I I) (str.indexof s "-" I)))))
(constraint (= (f "(425)-706-7709") "425-706-7709"))
(constraint (= (f "(510)-220-5586") "510-220-5586"))
(check-synth)
; (str.++ (str.substr s 1 3) (str.substr s (str.indexof s "-" 0) (str.len s)))
enumerative search: list expressions from smallest to largest, check each against spec, return first that passes.
bottom-up enumeration:
bank[1] = [x, y, 0, 1]
for n = 2, 3, 4, ...:
for each rule: # e.g. E ::= E + E
for each way to split n-1 among children:
combine expressions from bank
bank[n] = everything built this round
check each new E against spec
x+y, y-1, ...we can check against examples first:
eg. observational equivalence:
| expression | (-1,0) | (0,-1) | (-1,-1) | (0,0) |
|---|---|---|---|---|
x |
-1 | 0 | -1 | 0 |
x+0 |
-1 | 0 | -1 | 0 |
x-0 |
-1 | 0 | -1 | 0 |
ite(x<=x, x, y) |
-1 | 0 | -1 | 0 |
x+y |
-1 | -1 | -2 | 0 |
each column is an example input (x,y). first 4 rows agree on every example. they are the same expression. we can keep only smallest expression of each output signature.
where do examples come from?
counterexample-guided inductive synthesis (CEGIS):
eg. verifier is a standard verification query:
def spec(r):
return And(r >= x, r >= y, Or(r == x, r == y))
def verify(r): # r: candidate as Z3 term
s = Solver()
s.add(Not(spec(r))) # does some input break it?
if s.check() == unsat:
return None # verified
return s.model() # counterexample
verify(x + (y + 1)) # [y = 0, x = 0]
verify(If(x <= y, y, x)) # None
eg. CEGIS on max2
| iter | examples so far (x,y) | learner proposes | verifier says |
|---|---|---|---|
| 1 | none | x |
fails on (-1,0) |
| 2 | (-1,0) | y |
fails on (0,-1) |
| 3 | (-1,0), (0,-1) | 0 |
fails on (-1,-1) |
| 4 | (-1,0), (0,-1), (-1,-1) | x+(y+1) |
fails on (0,0) |
| 5 | ... and (0,0) | ite(x<=y, y, x) |
verified |
does CEGIS terminate?
however, search space grows exponentially. while smallest solution to max2 is size 6, smallest solution to max3 is size 16.
sketching: a program with hols (??). the synthesizer fills the hols so program meets spec.
eg. avg(a, b) = (a ?? b) + ((a ?? b) >> ??): first two holes are &, |, ^, +, third is a shift amount.
∃holes. ∀inputs into two quantifier-free problems and alternates between thema, b = BitVecs('a b', 32)
op1, op2 = Ints('op1 op2') # holes: which operator
k = BitVec('k', 32) # hole: shift amount
def OP(h, x, y): # 0: & 1: | 2: ^ 3: +
return If(h == 0, x & y, If(h == 1, x | y, If(h == 2, x ^ y, x + y)))
def sketch(x, y):
return OP(op1, x, y) + LShR(OP(op2, x, y), k)
def spec(x, y): # true average, one extra bit
return Extract(31, 0, LShR(ZeroExt(1, x) + ZeroExt(1, y), 1))
examples = []
while True:
learner = Solver()
# find holes that fit examples
learner.add(0 <= op1, op1 <= 3, 0 <= op2, op2 <= 3)
for (x, y) in examples:
learner.add(sketch(x, y) == spec(x, y))
if learner.check() == unsat:
break # no completion of the sketch works
h = learner.model()
holes = [(v, h.eval(v, True)) for v in (op1, op2, k)]
candidate = substitute(sketch(a, b), *holes) # a, b stay free
# look for a counterexample
verifier = Solver()
verifier.add(candidate != spec(a, b))
if verifier.check() == unsat:
break # verified
m = verifier.model()
examples.append((m.eval(a, True), m.eval(b, True)))
# (a & b) + ((a & b) >> 0) counterexample: a, b = (1840699758, 613567268)
# verified: (a & b) + ((a ^ b) >> 1)
two kinds of learner:
| enumerative | constraint-based (sketch) | |
|---|---|---|
| searches | all exprs in a grammar | completions of one sketch |
| learner | lists exprs by size, tests on examples | one SMT query over hole vars |
| good at | needs only a grammar from user | large constants, many holes at once |
| struggles with | large programs | someone must write the sketch |
eg. invariant synthesis as a SyGuS problem:
(set-logic LIA)
; x = 0; y = 0; while (x < 10) { x = x + 1; y = y + 2; } post: y == 20
(synth-fun inv ((x Int) (y Int)) Bool)
(declare-var x Int)
(declare-var y Int)
(declare-var x1 Int) ; x, y after one iteration
(declare-var y1 Int)
; establishment
(constraint (=> (and (= x 0) (= y 0)) (inv x y)))
; preservation
(constraint (=> (and (inv x y) (< x 10) (= x1 (+ x 1)) (= y1 (+ y 2)))
(inv x1 y1)))
; exit
(constraint (=> (and (inv x y) (>= x 10)) (= y 20)))
(check-synth)
without a grammar, cvc5 returns the disjunction of all 11 reachable states (x=0 ∧ y=0) ∨ (x=1 ∧ y=2) ∨ ... ∨ (x=10 ∧ y=20): correct but useless. we can add grammar:
; x = 0; y = 0 while (x < 10) { x = x + 1; y = y + 2; }
(synth-fun inv ((x Int) (y Int)) Bool
((B Bool) (E Int))
((B Bool ((<= E E) (= E E) (and B B)))
(E Int ((+ E E) x y 0 1 2 10)))
)
; (and (<= x 10) (= y (+ x x)))
; x = 0; while (x < 10) { x += 2; }
(synth-fun inv ((x Int)) Bool
((B Bool) (E Int))
((B Bool ((<= E E) (= E E) (and B B)))
(E Int ((+ E E) (mod E 2) x 0 1 2 10)))
)
; (and (<= x 10) (= y (+ x x)))
houdini: to make I, it needs a finite list built from templates, filled in with variables and constants in the program:
| template | our loop |
|---|---|
v≤c, v≥c, v=c (c: 0 or a constant in the code) |
x≤10, x≥0, y≥0, y≤20, y=0, x=0 |
y = kx (k: ratio of the increments) |
y=2x |
start big, then delete:
facts are only deleted never added.
why deleting is safe:
| step | candidate conjuncts |
|---|---|
| start | x≤10, x≥0, y≥0, y≤20, y=2x, y=0, x=0 |
| establishment | all hold at (0,0): keep all seven |
| preservation | CTI (0,0) ⇒ (1,2): y=0 and x=0 are false at(1,2), drop |
| preservation | no CTI: conjunction is inductive |
| result | x≤10 ∧ x≥0 ∧ y≥0 ∧ y≤20 ∧ y=2x (and exit holds) |
correct: but larger than neeed: x≥0 ∧ y≥0 ∧ y≤20 add nothing.
if we had removed 'x even constraint':
x = 0;
while (x < 10) x = x + 2;
// post: x == 10
then CTX x=9⇒11 deletes x≤10, and x≥0 alone cannot show x=10.
if the true invariant requires a ∨ b and the list does not contain it, houdini also fails.
three kinds of counterexample:
| failed check | solver returns | tells the learner |
|---|---|---|
| establishment | state s: P(s), but not I(s) | s is reachable, I must include it (positive) |
| exit | state s: I(s) ∧ ¬b, but not Q(s) | I must exclude s (negative) |
| preservation | pair s ⇒ s′: I(s) ∧ b, but not I(s′) | if I includes s, it must include s′ (implication) |
the third kind does not tell whether s is reachable.
why not just pick a label? take implication (0,-2) ⇒ (1,0); neither is reachable.
ICE learning:
candidate I
┌───────────────────────────┐ ┌──────────────────────┐
│ learner │ ==========> │ verifier │
│ find I that is consistent │ │ check establishment, │
│ with all the data so far │ <========== │ preservation, exit │
└───────────────────────────┘ └──────────────────────┘
positive / negative / implication
what learner must satisfy:
| data | rule for candidate I | how I can satisfy it |
|---|---|---|
| positive p | I(p) | include p |
| negative n | ¬I(n) | exclude n |
| implication s⇒s′ | I(s) ⇒ I(s′) | exclude s, or include s′ |
picking candidate:
def learn(pos, neg, imp):
for size in range(0, len(FACTS) + 1):
for conj in combinations(FACTS, size):
if all(holds(conj, p) for p in pos) and \
not any(holds(conj, n) for n in neg) and \
all(not holds(conj, s) or holds(conj, t) for s, t in imp):
return conj
return None # no conjunction fits
I ::= fact | I ∧ Iholds evaluates facts on concrete states: plain python, no solver| iter | candidate I | failed check | new data |
|---|---|---|---|
| 1 | true |
exit | negative (10,21) |
| 2 | y≤20 |
exit | negative (10,0) |
| 3 | y=2x |
exit | negative (11,22) |
| 4 | x=0 |
preservation | implication (0,-2) ⇒ (1,0) |
| 5 | x≤10 ∧ y=2x |
none | done |
comparison:
| Houdini | ICE | |
|---|---|---|
| direction | starts with all facts, only deletes | any candidate the learner likes |
| uses | CTIs, only to delete facts | positive, negative, and implication data |
| result | largest inductive conjunction | our learner: smallest consistent conjunction |
| on our loop | five facts, three redundant | x ≤ 10 ∧ y = 2x |
| learner | fixed | anything that respects the three rules |
the complete picture:
spec + grammar/sketch ─► learner (search) ⇄ verifier (SMT) ─► program
enumeration Z3 / cvc5
sketch holes lec 2-4
ICE learning
to compute max3, an enumerator that goes from smallest first must get past 97b smaller expressions:
(define-fun max3 ((x Int) (y Int) (z Int)) Int
(ite (<= z y) (ite (<= x y) y x) (ite (<= z x) x z)))
looking at answer, every branch returns x, y or z. all difficulty is in deciding which one to return. we can search two parts separately.
divide and conquer (terms and conditions):
x≤y, x≤z, y≤z, ...building the decision tree:
x≤z?
yes / \ no
y≤z? x≤y?
yes / \ no yes / \ no
return z return y return y return x
(0,-1,1) (0,1,0) (0,1,-1) (1,0,0)
(0,0,1) (1,0,-1)
(-1,0,1)
ite(x <= z, ite(y <= z, z, y), ite(x <= y, y, x))
bottom-up vs top-down:
| size | bottom-up (lec 5) | top-down |
|---|---|---|
| 1 | x y 0 1 |
?? |
| 3 | x+y, x<=y, ... |
ite(??, ??, ??) |
| ... | ite(x<=y, y, x) |
ite(x<=y, ??, ??) |
| 6 | ite(x<=y, y, x) |
eg. (deduction) we have examples [1,2,3] ⇒ [2,4,6], [5,0] ⇒ [10,0]. every program is one block applied to xs: xs, map(f, xs), filter(p, xs), reverse(xs), sort(xs). and f, p are small exprs over v, e.g. f ::= v | 0 | 1 | 2 | f + f | f * f.
reject a partial program early:
| candidate | what examples require | verdict |
|---|---|---|
xs, reverse(xs), sort(xs) |
no holes: just run them | wrong output |
filter(??p, xs) |
output is a sublist of input, but 4 ∉ [1,2,3] | impossible for every p |
map(??f, xs) |
output has same length as input (3 and 2) | keep |
rejecting filter(??p, xs) rejects every p at once, without building or running any of them. bottom-up would build and test each filter(p, xs) separately.
split the problem:
map(??f, xs) must map elem by elem:
f(1)=2, f(2)=4, f(3)=6, f(5)=10, f(0)=0v, 0, 1, 2 fail; v+v fits all five, so answer: map(v+v, xs)eg. (component-based synthesis), we want a small program to turn off the rightmost 1 bit of a 32-bit integer.
def slow(x):
for i in range(32):
if (x >> i) & 1:
return x & ~(1 << i)
return x
def fast(x): return x & (x - 1) # (Hacker's Delight)
a program is a list of lines:
t1 = ?? op ??
t2 = ?? op ??
...
return the last line
+ - & | ^ >>t1 = x - 1; t2 = t1 & x; from 24*54 programst1 = a & b; t2 = a ^ b; t3 = t2 >> 1; t4 = t3 + t1 from 168m programseg. (preferring likely programs) to write max2, smaller program is not always best:
x + (y + 1) size 5 ⇐ no one will writeite(x <= y, y, x) size 6real programs follow patterns: comparisons sit inside ite, constants are small, etc. ⇒ search programs in order of how likely they are.
| rule | prob |
|---|---|
E ::= x |
0.25 |
E ::= y |
0.25 |
E ::= 0 |
0.10 |
E ::= 1 |
0.10 |
E ::= E + E |
0.10 |
E ::= E - E |
0.05 |
E ::= ite(B, E, E) |
0.15 |
B ::= E <= E |
0.60 |
B ::= E = E |
0.20 |
... |
0.20 |
x + (y + 1) = 0.1*0.25*0.10*0.25*0.1 ≈ 0.00006ite(x <= y, y, x) = 0.15*0.6*0.25*0.25*0.25*0.25 ≈ 0.00035 ⇐ tried firstautomatic vs. interactive proving:
Lean 4: functional programming language + proof assistant. programs, specs and proofs in one language, checked by one small kernel.
def double (n : Nat) : Nat := n + n
#eval double 21 -- 42
a theorem = name + statement + proof:
theorem double_eq (n : Nat) : double n = 2 * n := by
unfold double -- replace double by its body
omega -- finish with arithmetic
Lean accepts it only if the proof establishes exactly this statement. no error = proved.
writing statements: a statement is itself a value, of type Prop.
| math | Lean |
|---|---|
| for every natural number n, P(n) | ∀ n : Nat, P n |
| P implies Q | P → Q |
| P and Q, P or Q, not P | P ∧ Q, P ∨ Q, ¬P |
| there is an x with P(x) | ∃ x, P x |
| f(x, y) | f x y |
hypotheses are arguments:
theorem le_chain (a b c : Nat) (h1 : a ≤ b) (h2 : b ≤ c) : a ≤ c := by ...
-- same theorem
theorem le_chain : ∀ a b c : Nat, a ≤ b → b ≤ c → a ≤ c := by ...
requires, statement after the colon ≈ ensuresproof state:
a b c : Nat ┐
h1 : a ≤ b ├ context: variables and hypotheses available
h2 : b ≤ c ┘
⊢ a ≤ c ← goal: what remains to be proved
eg. one tactic at a time
theorem and_swap (p q : Prop) : p ∧ q → q ∧ p := by
intro h -- h : p ∧ q ⊢ q ∧ p
obtain ⟨hp, hq⟩ := h -- hp : p, hq : q ⊢ q ∧ p
constructor -- two goals: ⊢ q (case left), ⊢ p (case right)
· exact hq
· exact hp
· focuses on one goal at a time.
| tactic | argument | effect on the goal |
|---|---|---|
intro h |
a new name | P → Q: assume P as h, goal Q |
exact e |
a proof of the goal, e.g. a hypothesis | closes the goal |
apply f |
a lemma whose conclusion matches the goal | new goals: f's missing hypotheses |
obtain ⟨a, b⟩ := h |
a hypothesis, names for its parts | splits h : P ∧ Q, or h : ∃ x, P x |
constructor |
none | P ∧ Q becomes two goals |
rw [h] |
equations | replaces the left side by the right side |
unfold f |
a definition | replaces f by its body |
induction n |
a variable | one goal per case |
lemmas are functions:
#check Nat.le_trans
-- Nat.le_trans {n m k : Nat} : n ≤ m → m ≤ k → n ≤ k
theorem le_chain (a b c : Nat) (h1 : a ≤ b) (h2 : b ≤ c) : a ≤ c := by
apply Nat.le_trans h1 -- ⊢ b ≤ c
exact h2
-- or in one step: exact Nat.le_trans h1 h2
apply supplies the first proof; the missing one becomes the new goal{} are implicit: inferred from the goal (n=a, m=b, k=c)rw: replace equals by equals
theorem rw_demo (a b c : Nat) (h1 : a = b) (h2 : b = c) : a + 1 = c + 1 := by
rw [h1] -- ⊢ b + 1 = c + 1
rw [h2] -- ⊢ c + 1 = c + 1, closed by rfl
rw [h] for h : x = y replaces every x in the goal by y, then tries rfl (closes any goal whose two sides are identical)rw [← h]: right to left. rw [h] at h3: rewrite hypothesis h3 instead of goalrw [Nat.add_comm]sorry: closes a goal without proof (placeholder). Lean warns about every one, like Verus's admit().
automation for small steps:
| tactic | what it does | example goal |
|---|---|---|
omega |
decides linear arithmetic over Nat and Int | n + n = 2 * n |
simp |
rewrites with a large set of simplification lemmas | xs ++ [] = xs |
decide |
evaluates a statement with no variables | 2 ^ 10 = 1024 |
grind |
SMT-style search: congruence closure, linear and polynomial arithmetic | a * (b + 1) = a * b + a |
inductive types:
inductive Nat where
| zero : Nat
| succ (n : Nat) : Nat
inductive List (α : Type) where
| nil : List α -- []
| cons (x : α) (xs : List α) : List α -- x :: xs
every value is built from finitely many constructors ⇒ functions defined by recursion, properties proved by induction
recursive definition:
def triangle : Nat → Nat
| 0 => 0
| n + 1 => (n + 1) + triangle n
#eval triangle 4 -- 10
lean checks termination itself (triangle (n + 1) calls triangle n), no decreases needed
induction:
theorem triangle_formula (n : Nat) : 2 * triangle n = n * (n + 1) := by
induction n with
| zero => rfl -- ⊢ 2 * triangle 0 = 0 * (0 + 1): both sides evaluate to 0
| succ k ih => -- ih : 2 * triangle k = k * (k + 1)
-- ⊢ 2 * triangle (k + 1) = (k + 1) * (k + 1 + 1)
rw [triangle] -- ⊢ 2 * (k + 1 + triangle k) = (k + 1) * (k + 1 + 1)
rw [Nat.mul_add, ih] -- ⊢ 2 * (k + 1) + k * (k + 1) = (k + 1) * (k + 1 + 1)
grind -- polynomial identity, no triangle left
induction n gives one goal per constructor of Nat. in succ case, ih is the statement for k, goal is the statement for k+1Nat.mul_add distributes 2 * (...) so that 2 * triangle k appears, then ih replaces itomega fails on the last goal: linear only, it treats each product as an unknowna proof is a term:
#print and_swap
-- theorem and_swap : ∀ (p q : Prop), p ∧ q → q ∧ p :=
-- fun p q h => And.casesOn h fun hp hq => ⟨hq, hp⟩
Mathlib: Lean's library of mathematics. much of the work is finding the right lemma: exact? searches the library (for le_chain it suggests exact Nat.le_trans h1 h2).
eg. Euclid: there are infinitely many primes
theorem infinitely_many_primes (n : ℕ) : ∃ p, n ≤ p ∧ p.Prime := by
let p := Nat.minFac (n.factorial + 1) -- smallest prime factor of n!+1
have hp : p.Prime := Nat.minFac_prime
(by have := Nat.factorial_pos n; omega)
refine ⟨p, ?_, hp⟩ -- witness p; goal left: n ≤ p
by_contra h -- suppose p < n
have h1 : p ∣ n.factorial := -- then p | n!
Nat.dvd_factorial hp.pos (by omega)
have h2 : p ∣ n.factorial + 1 := Nat.minFac_dvd _ -- and p | n!+1
have h3 : p ∣ 1 := (Nat.dvd_add_right h1).mp h2 -- so p | 1
exact hp.ne_one (Nat.dvd_one.mp h3) -- but p is prime: contradiction
have: prove an intermediate fact and name it. by_contra: assume negation of goal, goal becomes Falsewith an AI model proposing proofs and Lean checking them, this is CEGIS's propose-and-check loop with a far larger generator. Lean still can't tell whether the formal statement matches the intended claim (same as spec vs. intent in software).
functional programs are already Lean definitions:
def sum : List Nat → Nat
| [] => 0
| x :: xs => x + sum xs
theorem sum_append (xs ys : List Nat) : sum (xs ++ ys) = sum xs + sum ys := by
induction xs with
| nil => simp [sum]
| cons x xs ih =>
simp [sum, ih]
omega
real code has machine integers, mutation, loops, panics ⇒ translate it into Lean definitions, prove theorems about those.
Rust to Lean with Aeneas:
Charon Aeneas
Rust crate ──────► LLBC ──────────► Lean code ◄── proofs (developer)
lib.rs Rust's internal Code.lean Proofs.lean
form, cleaned up
what the translation looks like:
pub fn avg_bad(a: u32, b: u32) -> u32 {
(a + b) / 2
}
def avg_bad (a : Std.U32) (b : Std.U32) : Result Std.U32 := do
let i ← a + b
i / 2#u32
inductive Result (α : Type) where
| ok (v : α)
| fail (e : Error) -- e.g. integerOverflow
| div -- does not terminate
theorem avg_bad_overflows :
avg_bad 4294967295#u32 1#u32 = fail .integerOverflow := by
rfl -- concrete input: evaluate both sides
U32 is Lean's model of u32: a number with 0 ≤ val < 2³². x.val is displayed as ↑xlet i ← a + b stops the whole function if the addition fails (like ? in Rust)a specification is a theorem:
-- pub fn avg(a: u32, b: u32) -> u32 { a / 2 + b / 2 + (a % 2 + b % 2) / 2 }
theorem avg_spec (a b : U32) :
avg a b ⦃ r => r.val = (a.val + b.val) / 2 ⦄ := by
unfold avg
step*
f x ⦃ r => P r ⦄: f x does not fail, and its result r satisfies P r (= ensures + absence of panics)a.val, b.val are Nat, so a.val + b.val cannot overflowstep* walks the generated code one let at a time, proving each step automatically#check @U32.add_spec
-- U32.add_spec : ∀ {x y : U32}, ↑x + ↑y ≤ U32.max → x + y ⦃ z => ↑z = ↑x + ↑y ⦄
step applies the spec of the first let's operation; for addition, the requirement that the sum fits in 32 bits becomes a goalstep* leaves case hmax ⊢ ↑a + ↑b ≤ U32.max, which cannot be proved: the bug appears as an open goalmutable references become return values:
pub fn swap(a: &mut u32, b: &mut u32) {
let t = *a;
*a = *b;
*b = t;
}
def swap (a : Std.U32) (b : Std.U32) : Result (Std.U32 × Std.U32) := do
ok (b, a)
theorem swap_spec (a b : U32) : swap a b = ok (b, a) := by
rfl
&mut argument becomes an input, and its final value becomes an extra output*a or *b. in C any two pointers may alias, so C tools model memory explicitly insteadloops become recursive functions:
// 1 + 2 + ... + n
pub fn sum_to(n: u32) -> u32 {
let mut s = 0;
let mut i = 0;
while i < n {
i = i + 1;
s = s + i;
}
s
}
def sum_to_loop (n s i : Std.U32) : Result Std.U32 := do
if i < n
then
let i1 ← i + 1#u32
let s1 ← s + i1
sum_to_loop n s1 i1
else ok s
partial_fixpoint
def sum_to (n : Std.U32) : Result Std.U32 :=
sum_to_loop n 0#u32 0#u32
partial_fixpoint: Lean accepts the definition before anyone has shown the loop terminatesthe loop lemma is the loop invariant:
theorem sum_to_loop_spec (n s i : U32)
(hn : n.val ≤ 10000) -- keeps s within 32 bits
(hi : i.val ≤ n.val) -- invariant
(hs : s.val = triangle i.val) : -- invariant
sum_to_loop n s i ⦃ r => r.val = triangle n.val ⦄ := by -- postcond
unfold sum_to_loop
split -- one goal per branch of the if
· step*
· -- no overflow in s + i1
have h1 := triangle_formula i.val
have h2 : i.val * (i.val + 1) ≤ 10000 * 10001 :=
Nat.mul_le_mul (by omega) (by omega)
scalar_tac
· -- the invariant holds again
rw [s1_post, hs, i1_post, triangle]
omega
· -- the loop exits: i = n
have : i.val = n.val := by scalar_tac
simp [hs, this]
termination_by n.val - i.val
decreasing_by scalar_decr_tac
step* applies the lemma being proved (= induction hypothesis)triangle_formula: 2s = i(i+1) ≤ 10000*10001termination_by supplies the ranking function (≈ decreases)goals left after step* are the familiar obligations (lec 3):
| goal | obligation | meaning |
|---|---|---|
hmax |
no overflow | s + i1 fits in 32 bits |
hs |
preservation | after one iteration, invariant holds again |
isFalse |
exit | invariant and ¬(i < n) give the postcond |
in sum_to_spec |
establishment | invariant holds for s=0, i=0 (top-level theorem applies the lemma with these) |
Verus vs. Lean on the same loop (both need spec, loop invariant, ranking function):
| Verus | Aeneas + Lean | |
|---|---|---|
| invariant, ranking fn | annotations in the Rust source | hypotheses of a lemma about the loop function, termination_by |
| proof | Z3 proves the VCs | developer writes tactics, automation does the arithmetic |
| when stuck | "postcondition not satisfied" / timeout | open goal shows what is missing |
| trusted base | Verus, Z3 | Charon, Aeneas, Lean kernel |