One simple question, asked about your code:
“What must be true before this runs, so that what I want is true after?”
Figure out the exact input condition that makes code do the right thing
Read the code backwards, from the goal to the start
Catch bugs by thinking, not just by testing & hoping
Lots of concrete code and real numbers ahead — no heavy math needed. (Idea: Dijkstra; teaching style: Gries.)
Here's a tiny function. When is it safe to call? Testing a few inputs gives you some confidence. We want certainty.
def withdraw(balance): balance = balance - 100 return balance # We want one thing to always hold: # the balance must never go negative. # # So AFTER running: balance >= 0
balance = 500 → ends at 400. Fine.balance = 100 → ends at 0. Fine.balance = 50 → ends at −50. Bug!Tests found one bad case by luck. What we really want is the exact rule for when it's safe — for every input, not just the ones we tried.
That exact rule is the weakest precondition.
Every piece of code has an implied contract: “if X is true when you call me, then Y will be true when I'm done.”
What must be true when the code starts. The caller's responsibility.
e.g. balance >= 100
What you want to be true when the code finishes. The goal.
e.g. balance >= 0
# { before: balance >= 100 } balance = balance - 100 # { after: balance >= 0 }
Read it as a promise:
If balance >= 100 going in, then balance >= 0 coming out — guaranteed.
The curly-brace notation {before} code {after} is the standard way to write this. That's all it means.
wp Actually Means ONE QUESTIONwp(code, goal) answers:
“What's the condition on the input that guarantees the goal afterward?”
wp is short for weakest precondition. Give it two things:
and it hands back the before-condition you need.
“Guarantees” also means the code actually finishes — no infinite loops. We'll see how loops earn that on slides 11–13.
Many before-conditions would be “safe.” We want the one that rules out the fewest inputs — the most generous rule that still works.
withdraw, goal balance >= 0| Before-condition | Safe? | Verdict |
|---|---|---|
balance == 100 | yes | too strict — forbids 500 |
balance >= 1000 | yes | way too strict |
balance >= 100 | yes | just right — weakest |
balance >= 50 | no | too loose — 50 breaks it |
The weakest precondition is the line in the sand: everything on the safe side works, and it doesn't exclude a single input that would have been fine. That makes it the most reusable rule.
Start from the goal (the “after”) and push it back through each line until you reach the start. Let's do our withdrawal, with real numbers.
# goal (after): balance >= 0 # the line that runs: balance = balance - 100 # Ask: for the RESULT to be >= 0, # what must balance be BEFORE? # The result is (balance - 100), so: balance - 100 >= 0 balance >= 100 # answer!
| before | after −100 | >= 0 ? |
|---|---|---|
| 100 | 0 | yes |
| 150 | 50 | yes |
| 99 | −1 | no |
balance >= 100 is exactly the cut-off. We didn't guess-and-test — we computed it by substituting the new value back into the goal.
Notice we replaced balance in the goal with what it becomes: balance - 100. That's the one rule for assignments →
To find the before-condition of x = e:
take the goal, and replace every x with e.
# goal: x > 5 x = x + 1 # replace x with x+1 → x + 1 > 5 → x > 4
To end above 5 after adding 1, start above 4.
# goal: y == 10 y = y * 2 # replace y with y*2 → y * 2 == 10 → y == 5
To land on 10 after doubling, start at 5.
# goal: total <= 50 total = total + item → total+item <= 50 → total <= 50 - item
Room left before adding = 50 minus the item.
Common trap: people substitute into the before picture (“x becomes 5, so…”). Don't. You always substitute into the goal (the after), because that's the thing you're trying to make true.
For a sequence of statements, push the goal up through the last line first, then the line above, and so on.
x = x + 1 y = x * 2 # goal (after): y > 10
Step 1 — through y = x*2 goal y > 10, replace y with x*2 → x*2 > 10 → x > 5 Step 2 — through x = x+1 need x > 5, replace x with x+1 → x+1 > 5 → x > 4 # final answer
| start x | x+1 | y = 2× | y>10? |
|---|---|---|---|
| 5 | 6 | 12 | yes |
| 4 | 5 | 10 | no |
| 10 | 11 | 22 | yes |
x > 4 is exactly right — x = 4 just misses (y lands on 10, not above it), x = 5 just makes it.
Rule in one line: wp(A; B, goal) = wp(A, wp(B, goal)). Do the inner one (B) first.
For an if, the before-condition is: whichever branch runs, it must reach the goal. So check every path and require them all.
if x >= 0: r = x else: r = -x # goal: r == abs(x) (r is |x|)
Check each path:
# when x >= 0, we run r = x # is x == |x|? yes ✓ (x is non-neg) # when x < 0, we run r = -x # is -x == |x|? yes ✓ (x is neg)
Both branches reach the goal, so the before-condition is true — it works for every x. No precondition needed.
wp(if B: S1 else: S2, goal) = (B and wp(S1, goal)) or (not B and wp(S2, goal))
In words: either the test is true and the if-branch reaches the goal, or the test is false and the else-branch does. Miss a case (e.g. no else) and you must prove the goal already holds there.
You can't “substitute backward” through a loop — it might run 3 times or 3 million. Instead we describe the loop with two things we invent, then check a short list.
A fact that stays true every time around the loop — before it starts and after each pass.
Think: “what's my progress-so-far statement?” It captures the work done up to now.
This is the creative part — and the single most useful idea for real code.
A number that counts down — it stays > 0 while looping and shrinks every pass.
If something keeps getting smaller and can't go below zero, the loop must end. That's your termination proof.
Usually “how far left to go” — like n - i.
Slogan: the invariant says the loop is doing the right thing; the bound says it won't do it forever.
Add up 1 + 2 + … + n. Watch what stays true after every pass — that's the invariant.
i = 0 s = 0 while i != n: i = i + 1 s = s + i # goal: s == 1+2+...+n
Trace it for n = 4 and watch s vs 1..i:
| after pass | i | s | 1+..+i |
|---|---|---|---|
| start | 0 | 0 | 0 |
| 1 | 1 | 1 | 1 |
| 2 | 2 | 3 | 3 |
| 3 | 3 | 6 | 6 |
| 4 | 4 | 10 | 10 |
s == 1 + 2 + … + i
“s always holds the sum up to i so far.” True at the start (0 = empty sum) and true after every single pass — look at the table, the last two columns always match.
The loop stops when i == n. Plug that into the invariant:
s == 1 + 2 + … + n ✓
invariant (still true) + loop-finished (i == n) = the goal. That's the whole point of picking a good invariant.
For the sum loop, how much work is left? n - i. Watch it fall for n = 4:
| i | bound = n − i |
|---|---|
| 0 | 4 |
| 1 | 3 |
| 2 | 2 |
| 3 | 1 |
| 4 | 0 → loop stops |
i != n), the bound is > 0. ✓i goes up by 1, so n - i drops by 1). ✓A whole number that keeps dropping and never goes below 0 cannot drop forever — so the loop must end. Termination proven, no guesswork.
If you can't find such a countdown, that's a red flag your loop might hang.
Once you have an invariant P and a bound t, a correct loop is just these four checks. Here they are for the sum loop.
| Check | In plain words | Sum loop |
|---|---|---|
| 1 · Starts true | The setup makes the invariant hold before the first pass. | i=0, s=0 → s = empty sum = 0 ✓ |
| 2 · Stays true | If it's true and we loop once more, it's still true. | add i+1 to both i and s → still “s = 1..i” ✓ |
| 3 · Gives the goal | Invariant + loop-finished ⇒ the postcondition. | s=1..i and i=n → s=1..n ✓ |
| 4 · It ends | The bound is > 0 while looping and shrinks each pass. | n-i > 0, drops by 1 each time ✓ |
All four tick → the loop is correct and terminates, for every valid input. Checks 1–3 are the invariant's job; check 4 is the bound's job.
wp QUICK INTUITIONYou don't need these to use the method, but they explain why it behaves so predictably.
wp(code, false) = false
No input can make an impossible goal come true. Code can't perform magic.
If goal A is easier than goal B, then A needs a looser before-condition.
Asking for less never demands more of the caller.
wp(S, A and B)
= wp(S, A) and wp(S, B)
To hit two goals at once, hit each — then combine. Handy for proving compound goals piece by piece.
These are just consistency guarantees. If some rule you wrote ever broke one of them, the rule would be wrong — they're the guardrails that keep the whole calculus honest.
You don't have to write formal proofs to benefit. The habits show up directly in code you write today.
def withdraw(balance, amt): assert balance >= amt # the wp! return balance - amt # the assert IS the weakest # precondition, written down.
Just naming “what's true each pass” catches most loop bugs — boundaries, empty inputs, the last element — before you ever run the code.
“This line needs x > 0… so the line above must guarantee it…” is exactly wp, done by hand. It's how you localise a bug fast.
Verifiers like Dafny, Why3, and ESC/Java compute weakest preconditions under the hood to check code automatically. Same idea, scaled up.
| Code | Before-condition |
|---|---|
x = e | goal with x replaced by e |
A ; B | do B first, then A (bottom–up) |
if / else | each branch must reach the goal |
while | invariant + bound (4 checks) |
1 starts true · 2 stays true · 3 gives the goal · 4 it ends.