Weakest Preconditions
Hoare logic gives you the rules to verify a program, but it leaves you doing the creative work:
at every step you have to guess the assertion in the middle and then check it fits. For a
machine that is hopeless — you cannot ask a computer to "have an insight." In the mid-1970s Edsger
Dijkstra reorganised the whole logic around a single function that removes the guessing entirely. Give it
a command and a goal, and it computes the precondition you need. No search, no cleverness — just
substitution, run backwards through the code.
That function is the weakest precondition, written
\operatorname{wp}(C, Q). It turns
Hoare logic
from an art you practise into a calculation you can automate — the foundation of every modern program
verifier.
What "weakest" means
Fix a command C and a desired postcondition Q.
Many preconditions P might make the triple
\{P\}\,C\,\{Q\} valid — but they are not equally good. A precondition that is
too strong (demands too much) rules out starting states that would actually have worked. The
weakest precondition is the most permissive one: it accepts every starting
state from which running C is guaranteed to terminate in a state satisfying
Q, and no others.
\operatorname{wp}(C, Q) = \text{the weakest } P \text{ such that } \{P\}\, C\, \{Q\} \text{ holds for total correctness.}
It is a predicate transformer: feed in a predicate Q on the
final state, get out a predicate on the initial state. Crucially, Dijkstra defined it for
total correctness — a state satisfies \operatorname{wp}(C,Q)
only if C started there is certain to halt and land in
Q. Termination is baked in, not bolted on.
Think of \operatorname{wp}(C, Q) as describing the largest possible set of
safe launch pads. Strengthen it and you throw away good launch pads for no reason; weaken it and you
include pads from which the flight might crash or never land.
The equivalence that ties it to Hoare logic
Because \operatorname{wp} captures exactly the safe starting states,
checking any Hoare triple collapses into a single implication:
\{P\}\, C\, \{Q\} \quad\Longleftrightarrow\quad P \Rightarrow \operatorname{wp}(C, Q)
- Your program plus its spec generates one logical formula — the verification condition
P \Rightarrow \operatorname{wp}(C, Q).
- If that formula is a tautology, the program meets its spec. If not, the states where it fails are a
counterexample.
- The right-hand side is computed mechanically; only the final implication needs a theorem prover.
This is the whole trick of automated verification: reduce "does this code satisfy this contract?" to
"is this one formula valid?", and hand the formula to a solver.
The rules — one per construct
Each rule tells you how to push a postcondition backwards across one language construct. Applied
repeatedly, they walk from the end of the program to the start.
- skip: \operatorname{wp}(\texttt{skip}, Q) = Q — doing
nothing changes nothing.
- Assignment:
\operatorname{wp}(x := E,\; Q) = Q[x := E] — substitute
E for every free x in
Q. (This is Hoare's backwards assignment axiom, now read as a
function.)
- Sequence:
\operatorname{wp}(C_1; C_2,\; Q) = \operatorname{wp}\bigl(C_1,\; \operatorname{wp}(C_2, Q)\bigr)
— solve the second command first, then feed its precondition in as the postcondition of the
first. Composition of transformers.
- Conditional:
\operatorname{wp}(\texttt{if } b \texttt{ then } C_1 \texttt{ else } C_2,\; Q) = \bigl(b \Rightarrow \operatorname{wp}(C_1, Q)\bigr) \wedge \bigl(\neg b \Rightarrow \operatorname{wp}(C_2, Q)\bigr)
equivalently (b \wedge \operatorname{wp}(C_1,Q)) \vee (\neg b \wedge \operatorname{wp}(C_2,Q)):
whichever branch runs must be ready to establish Q.
Notice there is no case analysis to invent and no middle assertion to guess. The rules are purely
syntactic: they manipulate the text of Q. A program built from these four
constructs has its weakest precondition computed, not discovered.
A backward calculation, line by line
Let us verify that a three-line block leaves y holding twice the larger of two
inputs — postcondition Q \equiv y = 2\max(a,b). We start at the bottom with
Q and drag it upward, applying one rule per line.
// Goal Q: y === 2 * Math.max(a, b)
let m: number;
if (a >= b) { m = a; } else { m = b; } // (1) conditional
y = 2 * m; // (2) assignment y := 2*m
// Q here: y === 2 * Math.max(a, b)
Step 2 — assignment y := 2m. Substitute
2m for y in Q:
\operatorname{wp}(y := 2m,\; y = 2\max(a,b)) \;=\; \bigl(2m = 2\max(a,b)\bigr) \;=\; \bigl(m = \max(a,b)\bigr).
Step 1 — the conditional with this new goal R \equiv m = \max(a,b).
The then branch is m := a, giving
\operatorname{wp} = (a = \max(a,b)); the else branch
m := b gives (b = \max(a,b)). Assemble:
\bigl(a \ge b \Rightarrow a = \max(a,b)\bigr) \wedge \bigl(a < b \Rightarrow b = \max(a,b)\bigr).
Both implications are logically true (if a \ge b then indeed
a = \max(a,b), and likewise for the other), so the weakest precondition of the
whole block reduces to \texttt{true}. The program works for
every input — proved, without ever guessing an intermediate assertion. We simply computed.
Liberal preconditions: dropping termination
Sometimes you only care about partial correctness — "if it halts, the answer is right" — and are
content to argue termination separately. For that there is the twin transformer, the
weakest liberal precondition \operatorname{wlp}(C, Q).
- \operatorname{wlp}(C, Q) — the weakest P such
that \{P\}\,C\,\{Q\} holds for partial correctness: from a
state satisfying it, C either diverges or ends in
Q.
- \operatorname{wp}(C, Q) demands the same and that
C actually terminates.
- They agree exactly when C always halts. In general
\operatorname{wp}(C, Q) = \operatorname{wlp}(C, Q) \wedge \operatorname{wp}(C, \texttt{true}),
where \operatorname{wp}(C, \texttt{true}) is precisely the
"C terminates" predicate.
For the loop-free constructs above, \operatorname{wp} and
\operatorname{wlp} coincide — no loops, so nothing can diverge. The
difference only appears once loops (and their
variants)
enter the story.
The magic is that \operatorname{wp} for the loop-free fragment is
purely syntactic: every rule is either "leave it alone", "substitute", or "combine with
\wedge / \Rightarrow." Nothing requires
understanding what the program means; you push the postcondition backward through the parse tree
the way an interpreter pushes values forward. The output is one formula — the verification condition — and
deciding its validity is the only part that needs a theorem prover. This clean split, "compute the
VC syntactically, then discharge it," is exactly how tools like Dafny, Why3, and ESC/Java work: the wp
calculus is their engine room. Loops are the one place the syntactic march stalls, because you cannot
substitute your way across an unknown number of iterations — there you must supply an
invariant,
and the tool takes over again from both sides.
The single most common blunder is treating assignment forwards: given
x := x+1 and goal x > 0, students write the
precondition as "x > 0 then add one, so x > 1." Wrong
direction. The rule is \operatorname{wp}(x := E, Q) = Q[x := E]: substitute the
right-hand side into the postcondition, giving (x + 1) > 0, i.e.
x > -1. Sanity check it — starting from x = 0 (which
satisfies x > -1 but not x > 1) the assignment yields
x = 1 > 0. So x > 1 is far too strong; it needlessly
rejects the perfectly-good launch pad x = 0. Whenever your "precondition" looks
stronger than the postcondition after an increment, you have run the substitution the wrong way.