Theory
Practice's engine emits one Z3 query per leaf of the execution tree. The formula at each leaf is called the strongest postcondition. The formal rules for assert, assume, and havoc are how the engine builds it.
Where Practice Left Off
The engine ran my_abs and printed one record per path. Path 0 was:
Path 0: PC = (x0 < 0), return = -x0, assertion CAN fail
Read as a single formula about the inputs and outputs at this leaf:
This is the strongest claim the engine can make about the program on Path 0. It is called the strongest postcondition.
Mini IMP
The subset of Python that Practice's engine accepts is called mini IMP. The grammar:
e ::= n | x | -e | not e | e op e
op ::= + | - | * | // | == | != | < | <= | > | >= | and | or
s ::= x = e
| s; s
| if e: s else: s
| while e: s
| return e
| assert e
| assume e
| havoc x
A program is a function whose body is a sequence of statements. Every value is a 32-bit two's-complement integer.
The concrete state is a finite map from variable name to a 32-bit integer. Concrete execution threads through a body of statements top to bottom, applying one rule per statement.
Evaluating expressions
Every statement rule below evaluates its right-hand expression in the current state. The concrete evaluator eval(e, σ_c) pattern-matches on the form of e:
eval(e, σ_c):
match e:
case n where is_int(n): n
case x where is_var(x): σ_c[x]
case -e1: -eval(e1, σ_c)
case not e1: not eval(e1, σ_c)
case e1 op e2: apply(op, eval(e1, σ_c), eval(e2, σ_c))
The base cases are integer literals (which evaluate to themselves) and variables (which look up their bound value in ). Compound expressions recurse on subexpressions. The 32-bit two's-complement arithmetic wraps on overflow, which is what makes the INT_MIN bug in Practice's my_abs possible.
Basic statements
Assignment. x = e evaluates e under and rebinds x to the result.
Conditional. if e: S1 else: S2 evaluates e under . If the result is true, execution continues with S1; otherwise with S2.
Loop. while e: S evaluates e under . If true, execution runs S and re-evaluates e; if false, execution falls through.
Return. return e evaluates e and ends execution with the resulting value.
assert and assume
assert and assume look similar but play different roles in verification.
Assertion. assert e evaluates e. If e is true, execution continues. If e is false, the program fails. The engineer is claiming the program guarantees e. The verifier's job is to find an input that breaks the claim and report it as a counterexample.
Assumption. assume e evaluates e. If e is true, execution continues. If e is false, the path stalls and the verifier produces no result for it. The engineer is restricting the verifier's question to inputs that satisfy e. Inputs that fail are out of scope.
Both narrow the set of inputs the verifier continues to consider on a given path. They differ in what gets reported. A failed assert produces a counterexample, which is the kind of bug the verifier exists to find. A failed assume produces nothing, because the input lay outside the question being asked.
havoc
Havoc. havoc x rebinds x to any 32-bit value. The choice is nondeterministic: any value the type can hold.
Function parameters at the entry of a program are implicitly havoc'd. Every call site could pass any value, and the verifier must check the function for all of them. Explicit havoc introduces the same kind of fresh, unconstrained value at any point in a program.
The most common use is modeling an unknown environmental value. A sensor reading, a network response, or a user input is not known statically. The standard pattern is havoc followed by assume to record the range that the engineer trusts:
havoc temp
assume -50 <= temp <= 200
After these two statements, temp is any 32-bit value in , and the verifier checks downstream code for all such values.
L08 uses the same pattern at loop cuts. Havoc the variables the loop body modifies, then assume the loop invariant. What survives is exactly what the invariant promises about the state after an arbitrary iteration.
Concrete trace
The concrete trace of sign(-5):
| Step | Statement | |
|---|---|---|
| start | {x: -5} |
enter function |
| line 2 | {x: -5} |
if x > 0: guard -5 > 0 is false |
| line 4 | {x: -5} |
elif x < 0: guard -5 < 0 is true |
| line 5 | {x: -5, r: -1} |
r = -1 |
| line 8 | {x: -5, r: -1} |
return r returns -1 |
Symbolic execution lifts these rules to a symbolic state that maps variable names to Z3 expressions. At a branch, the engine forks rather than chooses; the guard goes into the path condition. The next section gives the lifted rules.
Symbolic state and path conditions
The symbolic state is a finite map from variable name to a Z3 expression. At program entry, every parameter maps to a fresh symbolic BitVec. Each assignment updates : the right-hand side is evaluated under the current state, and the resulting expression is bound to the assigned name.
The path condition is a quantifier-free formula over the initial inputs. Control reaches a program point on a given input exactly when that input satisfies . At the entry, . Each branch produces two children: the then-child extends with the guard, the else-child with the negated guard. Straight-line code does not change .
The execution tree of a program is the binary tree whose root is the entry, whose internal nodes are branch sites, and whose leaves are returns, fallthroughs, or paths that hit a BMC unroll bound. A leaf is feasible when its path condition is satisfiable and infeasible when it is not.
The function sign has three branches and three feasible leaves:
def sign(x):
if x > 0:
r = 1
elif x < 0:
r = -1
else:
r = 0
return r
The execution tree, with and at each node:
graph TD
accTitle: Execution tree for the sign function
accDescr: The tree forks twice. The root holds sigma mapping x to x0 with path condition top. The first branch on x0 greater than 0 produces a feasible leaf with r mapped to 1. The else branch forks again on x0 less than 0, producing a feasible leaf with r mapped to negative one, and an else branch producing a feasible leaf with r mapped to zero.
root["σ = {x ↦ x0}
φ = ⊤"]
root -->|x0 > 0| pos["σ = {x ↦ x0, r ↦ 1}
φ = x0 > 0
(return 1)"]
root -->|x0 ≤ 0| nz["σ = {x ↦ x0}
φ = x0 ≤ 0"]
nz -->|x0 < 0| neg["σ = {x ↦ x0, r ↦ -1}
φ = x0 ≤ 0 ∧ x0 < 0
(return -1)"]
nz -->|x0 ≥ 0| zero["σ = {x ↦ x0, r ↦ 0}
φ = x0 ≤ 0 ∧ x0 ≥ 0
(return 0)"]
classDef feasible fill:#d9f5c5,stroke:#5a8a3a
class pos,neg,zero feasibleAll three leaves are feasible. The path conditions partition the inputs into three regions: , , and the singleton . At each leaf, records what r holds on return.
The IVL primitives
A path through the program carries the current pair . The rule for s1; s2 runs the rule for s1 on the current path and then runs the rule for s2 on every path the first rule produced. The rule for if forks the path into two children, each of which continues independently. Most other rules update and return a single path. Each leaf the sweep produces is a finished path.
The rules for assignment and if were given symbolically in the previous section. The remaining statement kinds are assert, assume, and havoc. Each defines a procedure on the current path: do_assert, do_assume, and do_havoc. These three primitives come from intermediate verification languages (IVLs). IVLs are small languages like Boogie and Why3 that verification tools compile annotated programs into before generating proof obligations. Mini IMP is one such language, with while added. The primitives recur in the weakest-precondition machinery in L08 and in synthesis in weeks 9 and 10.
assert C
The statement assert C says: at this point, C must hold on the current path.
The procedure do_assert(C, σ, φ):
c = eval(C, σ)
if (φ ∧ ¬c) is sat:
report the model as a counterexample at this site
φ ← φ ∧ c
The solver query asks whether any input satisfies the path condition and falsifies C. A satisfying model is a concrete input that reaches the assertion and breaks it.
The last line is the "check, then assume" convention from the IVL tradition. After the engine has checked the assertion, downstream code on this path may rely on C. Without this step, two assertions in sequence would each see the path condition as it was on entry, and the engine would miss bugs that depend on the first assertion having held.
assume C
The statement assume C says: restrict attention to executions where C is true.
The procedure do_assume(C, σ, φ):
c = eval(C, σ)
φ ← φ ∧ c
if φ is now unsat, discard the path
The discard check at the end is optional. Some engines do it eagerly; others carry infeasible paths until a downstream do_assert exposes the contradiction. Both report the same set of failing inputs.
assume C is equivalent to if C: skip; else: abort. The only paths that continue past the statement are those satisfying C, which is exactly the effect of conjoining C onto .
In source code, assume(C) is a marker function. The runtime definition is a regular assert C, so a mini-IMP program runs concretely with assume behaving as a precondition check. The SE engine intercepts the marker and uses the rule above.
havoc x
The statement havoc x says: forget what we knew about x.
The procedure do_havoc(x, σ, φ):
σ[x] ← fresh symbolic variable
Nothing else changes. The path condition is unaffected. Downstream code sees x as an unconstrained value.
Symbolic execution rarely encounters havoc in source-level code. It shows up in L08 inside the loop-cut transformation. Synthesis tools also use it to model an angelic choice.
Strongest postcondition
Given a precondition and a program , the strongest postcondition is the strongest predicate such that every execution of from a state satisfying terminates in a state satisfying . "Strongest" means most informative: any other valid postcondition is implied by .
The predicate is always a valid postcondition: it holds in every state. But it says nothing about the program. The SP is the most specific characterization the engine can produce.
Reading the SP off the tree
The execution tree for sign has three feasible leaves. Each leaf carries a path condition and a symbolic state. The pair contributes one disjunct of the SP:
| Leaf | disjunct | ||
|---|---|---|---|
| positive | |||
| negative | |||
| zero |
The path conditions simplify by basic propositional reasoning: reduces to , and reduces to . The disjunction over all feasible leaves is the SP:
The formula reads as the specification of sign: either the input was positive and the result is 1, or the input was negative and the result is -1, or the input was zero and the result is 0.
The formal claim
Symbolic execution on a loop-free or loop-cut program from precondition computes . Each feasible leaf contributes one disjunct: the path condition conjoined with the equations recorded in . The disjunction over all feasible leaves is the SP.
For programs with unbounded loops, this construction does not produce a finite formula: a loop has infinitely many feasible leaves, one per iteration count. Bounded model checking unrolls each loop to a fixed depth, which the next section formalizes. Verification with loop invariants cuts the loop using the havoc-then-assume pattern, which L08 builds out.
Computing SP by rules
The SP function is defined inductively on the structure of the statement:
sp(s, P):
match s:
case x = e:
∃ x'. P[x ↦ x'] ∧ x = e[x ↦ x']
case s1; s2:
sp(s2, sp(s1, P))
case if e: s1 else: s2:
sp(s1, P ∧ e) ∨ sp(s2, P ∧ ¬e)
case assert e:
P ∧ e
case assume e:
P ∧ e
case havoc x:
∃ x'. P[x ↦ x']
# while: not finitely expressible without unrolling or a loop invariant
Assignment is the load-bearing case. After x = e, the new state binds to the right-hand side evaluated in the old state. Expressing this as a predicate over the new state needs a way to refer to the old value of . The fresh variable plays that role: restates the precondition with standing for the old , and equates the new to the right-hand side computed with the old . The existential then says "there was some old value of for which these things held."
A tiny example: is
The existential binds to the old value 5, and the new is . After simplification, the SP is .
The existential is awkward to query directly. Practice's engine eliminates it by introducing a fresh symbolic name for the new and conjoining the equation . This is dynamic single assignment, and the path's extra list is exactly the chain of these equations.
Composition runs SP forward: compute SP of from , then SP of from that result.
Branching extends with the guard along each side, computes the SP of each branch separately, and disjoins the results. This is what the engine does at every if site: clone the path, extend each clone's with the guard or its negation, and continue independently.
Both assert e and assume e produce the SP . The two rules look identical because the SP only describes what is true after a successful execution. For assert e, the engine has an additional check to perform. Any input satisfying reaches the assertion and breaks it, so the engine must verify that is unsatisfiable. The do_assert rule from the IVL primitives section does this check before extending the path condition. assume e has no such check.
The rule for havoc x is the same existential elimination as the assignment rule, with no equation to constrain the new value. The SP forgets what the precondition said about .
The rule for while is missing because the SP is not finitely expressible by these inductive rules alone. Bounded model checking unrolls the loop into a finite chain of conditionals (next section). Loop-invariant verification cuts the loop at the invariant (L08).
Per-leaf queries vs the whole-program SP
The engine never builds the full SP disjunction. After an assertion Q placed at the end of the program, the engine asks Z3 one question per leaf instead.
At a leaf with path condition and symbolic state , let . The engine asks:
A satisfying model is an input that reaches this leaf and falsifies Q. UNSAT means Q holds on this leaf. Per-leaf UNSAT for every leaf is equivalent to the whole-program implication , so the per-leaf check answers the same question as the whole-program check. The per-leaf queries are smaller, and a counterexample at one leaf names the exact input that reaches that leaf and breaks the assertion.
When the engine runs in DSA mode (Practice's encoding section), binds program variables to fresh symbols and the equations relating those symbols to their right-hand sides live in a side list called extra. The query at each leaf is then , with extra serving as the bridge between the path condition and the state. The substitution-mode formulation above and the DSA formulation are equivalent: both encode the same logical content, only the bookkeeping differs.
Three solver outcomes
A Z3 query on a leaf returns one of three answers:
- UNSAT. No input satisfies . The assertion holds on this path.
- SAT, with model. The model is a concrete input that reaches the leaf and breaks the assertion. The engine reports it as a counterexample.
- UNKNOWN. Z3 gave up before deciding, typically because the formula is too expensive. The engine knows nothing about this leaf.
UNKNOWN is the most common signal that the encoding has outgrown the solver. The cascade demo from Practice was an example: the naive substitution encoding produced formulas Z3 could not finish, while DSA produced formulas it dispatched in milliseconds. Both encodings asked the same logical question; only one let the solver answer.
Soundness of SP
Theorem (Soundness of SP). Let be a concrete state satisfying . If the concrete execution of from terminates in a state , then satisfies .
The theorem says every concrete run that succeeds lands at a state described by the SP. The SP is a faithful summary of every successful trace.
The proof is by induction on the structure of . Six cases illustrate the pattern.
Case x = e. Suppose satisfies and the concrete execution of x = e from terminates in a state . The goal is to show satisfies .
- satisfies by hypothesis.
- The concrete semantics of
x = eproduces that agrees with on every variable other than , with . - The SP rule asks for a witness for . Pick , the value held before the assignment.
- is with every occurrence of replaced by . With bound to , evaluating on uses for the substituted-in and uses for every other variable. So on gives the same result as on , which holds by hypothesis.
- is with every occurrence of replaced by . With , evaluating on gives . The left side also equals by the concrete semantics. So holds on .
- Both conjuncts hold under the chosen witness, so holds on .
The witness is the key. The existential in the SP rule is precisely "there was an old value of for which the precondition held"; the concrete state gives us that old value directly.
Case s1; s2. Suppose satisfies and the concrete execution of s1; s2 from terminates in a state . The goal is to show satisfies .
- satisfies by hypothesis.
- The concrete semantics of
s1; s2decomposes the execution: it terminates in exactly when there is an intermediate state such thats1from terminates in ands2from terminates in . - The inductive hypothesis on
s1(with precondition ) gives that satisfies . - The inductive hypothesis on
s2(with precondition ) gives that satisfies . - By the SP rule for composition, . The SP holds on .
Case if e: s1 else: s2. Suppose satisfies and the concrete execution of if e: s1 else: s2 from terminates in a state . The SP rule gives as the postcondition. The goal is to show satisfies this disjunction.
The concrete semantics of if branches on whether evaluates to true or false under . The proof splits into the same two subcases.
Subcase: holds on .
- satisfies by hypothesis, and holds on in this subcase. So satisfies .
- The concrete semantics of
ifrunss1from , terminating in . - The inductive hypothesis on
s1(with precondition ) gives that satisfies . - A state satisfying one disjunct satisfies the disjunction, so satisfies .
Subcase: fails on .
- satisfies by hypothesis, and holds on in this subcase. So satisfies .
- The concrete semantics of
ifrunss2from , terminating in . - The inductive hypothesis on
s2(with precondition ) gives that satisfies . - satisfies one disjunct of , so it satisfies the disjunction.
In both subcases, satisfies the disjunction, which is the SP of the if-statement.
Case assert e. Suppose satisfies and the concrete execution of assert e from terminates in a state . The goal is to show satisfies .
- satisfies by hypothesis.
- Termination of
assert erequires to evaluate to true under . If were false, the program would fail rather than terminate in a state. So holds on . - The concrete semantics of
assert(when it does not fail) leaves the state unchanged: . - The SP rule says .
- On , holds by hypothesis and holds by the second bullet. So holds on .
- Since , holds on too.
The assert and assume cases coincide because the soundness theorem only considers terminating executions. Both assert e and assume e terminate exactly when holds, so their SPs agree. The difference between the two statements lives in the side obligation that do_assert discharges, which is separate from the SP rule.
Case assume e. Suppose satisfies and the concrete execution of assume e from terminates in a state . The goal is to show satisfies .
- satisfies by hypothesis.
- Termination of
assume erequires to evaluate to true under (otherwise the path would stall). So holds on . - The concrete semantics of
assumeleaves the state unchanged: . - The SP rule for
assumesays . - On , holds by hypothesis and holds by the second bullet. So holds on .
- Since , holds on too. The SP holds on the terminating state.
Case havoc x. Suppose satisfies and the concrete execution of havoc x from terminates in a state . The goal is to show satisfies .
- satisfies by hypothesis.
- The concrete semantics of
havoc xproduces that agrees with on every variable other than . The value of is some 32-bit integer chosen nondeterministically. - The SP rule asks for a witness for . Pick , the value held before the havoc.
- is with every occurrence of replaced by . With bound to , evaluating on uses for the substituted-in and uses for every other variable. So on gives the same result as on , which holds by hypothesis.
- The chosen witness satisfies the existential, so holds on .
Havoc mirrors assignment in shape. Both introduce an existential over the old value of , and both pick as the witness. The assignment rule additionally conjoins the equation to pin the new value of to the right-hand side. Havoc imposes no such constraint. After havoc, the verifier knows what said about the old (via ) but nothing about the new .
Why this matters. Soundness is what makes a satisfiable formula correspond to a real bug rather than a logical curiosity. Every concrete trace of a successful run agrees with the SP, so a state that satisfies the SP and falsifies an assertion is a state reachable by some concrete input. Finding a satisfying model is finding a concrete input that breaks the program.
Bounded model checking
The treatment above assumes the program is loop-free. Loops require a separate idea.
The unrolling transformation
Bounded model checking unrolls while C do S to a fixed depth :
After unrolling, the program is loop-free and the SP rules apply. Paths can exit at any of the nested conditionals, depending on when the loop guard becomes false. Paths that still satisfy the guard at the bottom of the unrolling are flagged bounded-out: the engine reports no result for them.
Soundness up to the bound
If no feasible path through the depth- unrolling falsifies any assertion, then no execution of the original program with at most iterations of any loop falsifies the assertion. Outside the bound, BMC makes no claim. For programs with naturally bounded loops (firmware, embedded code, parsers with bounded input), the bound is honest. For programs whose loops range over unbounded inputs, BMC trades unbounded soundness for full automation.
Saturation: when the bound becomes a proof
A bounded-out path carries a path condition that encodes "the loop guard remained true through iterations." That condition might be satisfiable (some input drives the loop that far) or unsatisfiable (no input does). When every bounded-out path's path condition is unsatisfiable, no input can keep any loop alive past iterations. The depth- unrolling has captured every execution, and the bound is no longer a bound. The result is a full proof.
This is the saturation condition. Practice's capped sum_to_n example (with assume(n <= 5)) saturates at : at that depth, no feasible bounded-out path remains, and BMC's bug-finding query has become a full verification result. Practice's uncapped sum_to_n does not saturate at any : the input keeps the loop alive past every unrolling.
CBMC names this condition with an unwinding assertion: a synthetic assertion at the bottom of the unrolling that says "the loop guard is false here." When the unwinding assertion holds under every input, BMC has saturated. CBMC prints "VERIFIED" rather than "no bug within ."
Production tools
CBMC, KLEE, JPF, and SAGE all use bounded unrolling at their core. They differ in heap models, environment models, and search heuristics. The unrolling itself is the same across all of them.
SP and WP
There is a second way to generate a verification condition. Symbolic execution starts at the precondition and pushes facts forward through the program until each leaf has its SP. The dual starts at the postcondition and pulls obligations backward through the program until the entry has its weakest precondition .
The two coincide on the verification question. The Hoare triple is valid if and only if
which is equivalent to
The two formulas are sound by the same theorem. They differ in which end of the program you start from, and in which loop-handling techniques apply cleanly.
Symbolic execution computes the SP form. The engine from Practice produces SP formulas, one per path. Production tools that follow this pattern (CBMC, Klee, KeY) tend to be best at finding bugs up to a bound, with full automation.
Dafny, Why3, Boogie, F*, and the verification mode of Frama-C compute the WP form. The user annotates loops with invariants; the WP machinery turns each annotated procedure into a finite set of verification conditions that Z3 can dispatch. The trade-off is annotation burden for unbounded soundness.
L08 builds the WP machinery and the loop-cut transformation that makes it work.