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.)
Which starting states let a program finish with the required result?
Understand the data, the rule, and the required result. Then run the complete Python program and modify one thing at a time.
No companion presentation is required. Python syntax and any mathematical notation are explained as they are introduced.
A mapping from variable names to current values.
Evaluate the right side, then update a name.
A true/false statement about a state.
The most permissive condition guaranteeing normal termination and the goal.
Trace each step and name what changed. Ask which fact stayed true and what should happen on an empty, invalid, or boundary input.
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 15 to 20.
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.
An if, then an assignment. We use all three rules, one step at a time, and never guess. All numbers are integers.
if a > b:
m = a
else:
m = b
m = m + 1
# goal: m > a and m > b
In words: “m ends up bigger than both.” When is that guaranteed?
The before-condition is true. It works for every a and b, and the derivation proves it.
Step 1 — through m = m + 1 (Rule 1)
replace m with m+1 in the goal
→ m+1 > a and m+1 > b
→ m >= a and m >= b # integers
Step 2 — through the if (Rule 3)
branch a > b runs m = a:
a >= a and a >= b → true (a > b)
branch a <= b runs m = b:
b >= a and b >= b → true (a <= b)
(a>b and true) or (a<=b and true)
→ true # final answer
Step 1 used a small integer fact: m + 1 > a is the same as m ≥ a. With real numbers you would keep m > a - 1 instead.
else, and Code That Crashes EASY TO MISSPython supports negative indices from -len(a). Use 0≤i only when it is part of your API's nonnegative-index convention.
if with no elseA missing else is really else: pass. That path changes nothing, so the goal must already be true there.
if x > 10:
x = x - 10
# goal: x <= 10
# test true : x > 10 and x-10 <= 10 → 10 < x <= 20
# test false: x <= 10 and x <= 10 → x <= 10
# together : x <= 20
So x = 25 breaks it: it becomes 15. One subtraction is not enough. A test with x = 12 would never show you that.
Code that crashes never reaches the goal. So wp quietly adds “this line can run at all.”
y = 10 / x # goal: y > 2 # replace y: 10 / x > 2 # plus defined: x != 0 # solve: 0 < x < 5
| x | 10 / x | > 2 ? |
|---|---|---|
| 4 | 2.5 | yes |
| 5 | 2.0 | no |
| 0 | crash | never gets there |
Python list indexing is defined for -len(a) ≤ i < len(a). If an API promises only nonnegative indices, add 0 ≤ i as a separate contract condition. Definedness alone does not forbid Python's negative indices.
Invariants yield sufficient conditions; the exact total-correctness weakest precondition has a least-fixed-point characterization and is not computable for arbitrary programs.
An arbitrary loop needs a fixed-point or inductive argument rather than one assignment-style substitution. Invariants plus a well-founded variant prove sufficient input conditions; they need not produce the exact weakest precondition.
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 nonnegative integer variant strictly decreases while the loop runs. The loop may stop before the variant reaches zero.
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 and 0 ≤ i ≤ n
“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 second part, 0 ≤ i ≤ n, looks boring but matters: it needs the precondition n ≥ 0. Call it with n = -1 and i != n is true forever.
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. ✓ (the invariant says i ≤ n, and i != n, so i < n)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 ✓ |
Invariant used here: s == 1+..+i and 0 ≤ i ≤ n, with precondition n ≥ 0. Gries' book splits check 4 in two (“bound > 0 while looping” and “bound drops each pass”), so you will also see this called the five checks.
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.
Look through list b (length n) for the first place that holds x. Same four checks, new loop.
i = 0
while i < n and b[i] != x:
i = i + 1
# goal: i == n and x is not in b
# or i < n and b[i] is the FIRST x
Invariant: 0 ≤ i ≤ n and x is not in b[0..i-1] (“everything I passed was not x”).
Bound: n - i.
Trace b = [4, 8, 15, 16], x = 15:
| i | b[i] | passed so far | bound |
|---|---|---|---|
| 0 | 4 | (none) | 4 |
| 1 | 8 | 4 | 3 |
| 2 | 15 → stop | 4, 8 | 2 |
1 Starts true — i = 0: nothing passed yet, and “x is not in an empty list” is true. ✓
2 Stays true — we only step past b[i] when b[i] != x, so “not in b[0..i]” still holds. And i < n, so i+1 ≤ n. ✓
3 Gives the goal — the loop stops for one of two reasons. i == n: x is in none of b. b[i] == x: found, and it is the first, because none before it matched. ✓
4 It ends — the guard says i < n, so n - i > 0; each pass drops it by 1. ✓
Order matters: i < n and b[i] != x checks the index first. Python's and stops early, so b[n] is never read. Swap the two tests and you get an IndexError when x is missing.
The list a is sorted. Find the leftmost spot where x could go. This loop is famous for off-by-one bugs; the invariant removes them.
lo, hi = 0, len(a)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] < x:
lo = mid + 1
else:
hi = mid
# goal: everything in a[:lo] < x
# everything in a[lo:] >= x
Picture of the invariant:
0 lo hi n | all < x | not seen | all >= x |
Bound: hi - lo, the size of the “not seen” part.
a = [2, 5, 5, 9, 12], x = 5| lo | hi | mid | a[mid] | move | bound |
|---|---|---|---|---|---|
| 0 | 5 | 2 | 5 | hi = 2 | 5 |
| 0 | 2 | 1 | 5 | hi = 1 | 2 |
| 0 | 1 | 0 | 2 | lo = 1 | 1 |
| 1 | 1 | stop: answer 1 | 0 | ||
1 Start: both outer parts are empty, so true. ✓
2 a[mid] < x means mid and all left of it are < x (sorted), so lo = mid + 1 is safe. Otherwise mid and all right of it are ≥ x, so hi = mid. ✓
3 Stop means lo == hi: the middle part is empty, and that is the goal. ✓
4 lo ≤ mid < hi, so both moves shrink hi - lo by at least 1. ✓
Write hi = mid - 1 and check 2 fails: mid might be the answer, and you just threw it away.
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.
Almost every wrong answer on a wp exercise is one of these six.
| Mistake | Why it is wrong | Fix |
|---|---|---|
| Working top-down | The goal lives at the end. Going forward you have nothing to substitute into. | Start at the last line and move up. |
| Substituting the wrong way | For x = x + 1 with goal x > 5, writing x - 1 > 5 runs the line backwards. wp is not “undo.” | Replace x with the right-hand side, exactly: x + 1 > 5. |
| Forgetting the silent path | An if with no else still has a second path, where nothing happens. | Add (not B and goal) for the missing branch. |
| Account for expression-definedness. Division needs a nonzero divisor; Python indices may be negative down to -len(a), unless the API specifically excludes them. | a[i] and y / x need 0 ≤ i < len(a) and x != 0. | Add the “defined” part to every step. |
| An invariant that is too weak | i ≥ 0 is true every pass, but check 3 fails: it says nothing about s. | Ask: “at the end, does invariant + stop give the goal?” |
| A bound that can stall | If one branch of the body does not change the bound, the loop can spin forever. | Check every branch shrinks it by at least 1. |
Self-check habit: after you compute a wp, test it on one value just inside the line and one just outside. If x > 4 is your answer, try x = 5 and x = 4.
Work each one on paper. Then click “Answer” to compare. All variables are integers.
wp(x = x - 3, x ≥ 0)
Replace x with x - 3: x - 3 ≥ 0, so x ≥ 3.
x = 2*x; y = x + 1, goal y > 7
Through y = x+1: x > 6. Through x = 2*x: 2x > 6, so x > 3. Try 4: 8, 9 ✓. Try 3: 6, 7 ✗.
if x < 0: x = -x, goal x > 0
(x < 0 and -x > 0) or (x ≥ 0 and x > 0) = x < 0 or x > 0 = x != 0. Zero stays zero.
y = 12 // x, goal y == 3
Need x != 0 and 12 // x == 3. Only x == 4 works (12 // 3 is 4, 12 // 5 is 2).
i, p = 0, 1
while i != n:
p = p * 2; i = i + 1
# goal: p == 2**n
Invariant p == 2**i and 0 ≤ i ≤ n. Bound n - i. Needs n ≥ 0.
i = 0
while i < n:
if b[i] == 0: continue
i = i + 1
Check 4 fails. When b[i] == 0 the bound n - i does not drop, so the loop never ends.
| Precondition | What must be true when code starts. The caller's job. |
| Postcondition | What will be true when code ends. The goal. |
| Hoare triple | {P} S {Q}: if P holds before S, then Q holds after. |
| wp(S, Q) | The weakest precondition: the most generous P that makes {P} S {Q} true. |
| Stronger / weaker | A is stronger than B if A ⇒ B. Stronger = fewer states allowed. |
| Implies (⇒) | A ⇒ B: whenever A is true, B is too. {P} S {Q} holds exactly when P ⇒ wp(S, Q). |
| Guard | The test of an if or while, like i != n. |
| Loop invariant | A fact true before the loop and after every pass. Your “progress so far.” |
| Bound function | A whole number, > 0 while looping, that drops every pass. Proves the loop ends. |
| Partial correctness | “If it ends, the answer is right.” Checks 1–3. |
| Total correctness | Partial correctness plus it always ends. All 4 checks. wp means this one. |
| Defined | An expression can be computed: no divide by zero, no bad index. |
| 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 |
no else | the silent path must already meet the goal |
a[i], y / x | add “defined”: index in range, divisor ≠ 0 |
while | invariant + bound (4 checks) |
1 starts true · 2 stays true · 3 gives the goal · 4 it ends.
Which starting states let a program finish with the required result?
The file contains its own imports, data generator or fixture, implementation, and checks.
python3 -m venv .venv source .venv/bin/activate # No third-party packages required python weakest_preconditions.py python weakest_preconditions.py --self-test
Use Python 3.10+. On Windows, activate with .venv\Scripts\Activate.ps1. Required packages: Python standard library.
Read the contract first. Identify the inputs, intermediate state, and return value in this excerpt from the verified file.
def wp_at(transform, goal, state):
"""Pre: transform terminates. Post: True iff it completes normally and meets goal."""
try:
return bool(goal(transform(dict(state))))
except (ZeroDivisionError, IndexError, KeyError):
return FalseSafe starting balances in 95..105: [100, 101, 102, 103, 104, 105] 12//x==3, safe x in -5..8: [4] Python index -1 is defined: True
Output was captured from the complete program running in an isolated temporary directory. Floating-point digits and compressed sizes may vary across versions/hardware.
533 checks passed, including boundary cases and independent reference comparisons where applicable. These executed checks complement the reasoning; they are not a universal proof.
Derive the precondition for x=2*x; y=x+1 with goal y>7. Then verify it on integers −10 through 10.
Substitute through y first: x>6. Then through x: 2x>6, so original x>3. This finite test supports the derivation; it is not the general proof.