How the Frml Verifier Works¶
This tutorial walks through the verification engine in frml/prover.py.
It explains, step by step, how a Frml program becomes Boolean logic that the
Z3 solver can check, and what Z3 does with that logic.
Who this is for¶
- You are an experienced programmer.
- You understand basic Boolean logic (
and,or,not, implication). - You have no prior background in formal verification.
- You already know what a lexer, parser, and AST are, so this tutorial skips them.
- You want to see the verifier, not the whole compiler.
What you will get out of it¶
- You will understand what a "verification condition" is.
- You will see how contracts, loops, arrays, calls, and quantifiers become Z3 formulas.
- You will know what
unsat,sat, andunknownmean for a proof. - You will be able to read
frml/prover.pyand follow what each function does.
The two files that matter¶
frml/cli.pyruns the verifier and printsVERIFIED,FAILED, orUNKNOWN.frml/prover.pygenerates verification conditions and checks them with Z3.
The rest of frml/ (lexer, parser, typechecker, interpreter) prepares the program,
but is not the topic here.
The big picture¶
- A Frml program is text.
- The lexer and parser turn the text into an AST.
- The typechecker rejects programs with type errors.
- The prover turns the AST into a list of verification conditions.
- Each verification condition is a logical claim of the form:
- The prover asks Z3 to confirm every verification condition.
- If Z3 confirms all of them, the program is
VERIFIED.
The pipeline is:
The single idea behind the verifier¶
- A proof obligation is a claim:
- In symbols:
h1 ... hnare facts known at some point in the program.goalis a fact that must hold at that point.
Example claims from a real program:
- "knowing
x >= 0, prove0 <= x" - "knowing
i < nandi <= n, provei + 1 <= n" - "knowing nothing, prove
x > x" (this one is false)
The prover's whole job is to produce claims in this shape.
What Z3 is and what it does¶
- A formula is satisfiable when some assignment of values to its variables makes it true.
- Z3 is an SMT solver.
- SMT stands for Satisfiability Modulo Theories.
- Z3 knows the theory of integers, arrays, strings, and Boolean logic.
Given a formula, Z3 returns one of three answers:
sat: a satisfying assignment exists and Z3 can show you one.unsat: no satisfying assignment exists.unknown: Z3 gave up or timed out.
Example:
From "is it valid?" to "is it unsatisfiable?"¶
- The prover wants to prove that
hypotheses => goalis valid. - Valid means true for every possible assignment of the variables.
- Z3 does not directly check validity: it checks satisfiability using a standard trick.
- The two questions are connected:
A => Bis valid exactly whennot (A => B)is unsatisfiable. - So the prover asks Z3:
- If Z3 says
unsat, then no counterexample exists, so the goal is proved. - If Z3 says
sat, it found a counterexample, so the goal is false. - If Z3 says
unknown, the proof is inconclusive. - That is the entire proof mechanism.
- Everything else is just building the hypotheses and goals.
Using Z3 in Python¶
A,B, andCdon't have values- Instead, each represents the set of possible Boolean values
- Specify constraints such as
A == B
- Then ask Z3 to find a model that satisfies those constraints
An example of unsatisfiability¶
- Require
Ato equalBandBto equalCbutAandCto be unequal
A = Bool("A")
B = Bool("B")
C = Bool("C")
solver = Solver()
solver.add(A == B)
solver.add(B == C)
solver.add(A != C)
report("A == B & B == C & B != C", solver.check())
The prover's data model¶
- The prover tracks a program point with a
Stateobject.State.vars: symbolic values of scalar variables.State.arrays: symbolic values of arrays.State.path: every fact assumed to hold so far.State.old_vars: snapshot of scalar values at function entry.State.old_arrays: snapshot of arrays at function entry.
- A scalar variable holds a Z3 term, not a concrete number.
xmight hold the symbolic integerx!1, not the number 5.x!1means "the first fresh symbol namedx".
- An array is an
ArrayValwith two parts:term: a Z3Array(Int, elem)map from indexes to elements.length: a separate symbolic integer for the length.
- An
Obligationrecords one claim to prove.kind:postcondition,precondition,assert,bounds,division,invariant,termination, ordecreases.description: human readable text of the claim.hyp: the list of hypothesis terms.goal: the goal term.line,col: where in the source the claim came from.
- The prover never stores concrete numbers in variables: it stores formulas about numbers.
Fresh names¶
- The prover keeps a counter and calls
_fresh(name). - Each call returns
name!Nwith an increasingN.x!1,x!2,x!3are different symbols even though they all relate to the source variablex.
- Why this matters:
- When
xis reassigned, the oldx!1is not overwritten. - The new value becomes a new symbol such as
x!5. - This keeps the symbolic execution correct.
- When
- Example:
- After this statement, the state maps
xto the termx!1 + 1, not to a number.
Some simple examples¶
- Frml program:
- The
simplefunction has norequiresclause, so the symbolic path starts empty. let x: Int = 3;evaluates the literal3and stores it instate.vars["x"]as the Z3 integer3(not a fresh symbol).- At
assert x > 0;, the prover evaluates the assertion expression with the current state.xlooks up the stored term3.>builds the Z3 term3 > 0.
- The prover emits one obligation:
- kind:
assert - description:
assert (x > 0) - hypotheses: the current path (empty here)
- goal:
3 > 0
- kind:
- In the Z3 check loop, this obligation becomes
not (True => 3 > 0), which simplifies tonot (3 > 0). - Z3 finds no assignment that makes
not (3 > 0)true, so it returnsunsat. unsatmeans the goal holds, so the assert isVERIFIED.- It is the only obligation, so the whole program is
VERIFIED. - Running
uv run frml check --trace examples/ex01_assign_then_assert.frmlprints:
- Let's try two variables:
- Once again the symbolic path starts empty.
let x: Int = 3;evaluates the literal3and stores it instate.vars["x"]as the Z3 integer3.let y: Int = x + 2;evaluatesx + 2with the current state.xlooks up the stored term3.+builds the Z3 term3 + 2.- The prover stores that term in
state.vars["y"].
- At
assert y > x;, the prover evaluates the assertion expression with the current state.ylooks up the stored term3 + 2.xlooks up the stored term3.>builds the Z3 term(3 + 2) > 3.
- The prover emits an obligation with the current (empty) path and the goal
(3 + 2) > 3 - No assignment makes
not ((3 + 2) > 3)true, so it returnsunsat, and the assertion is verified. - Running
uv run frml check --trace examples/ex02_assign_then_add.frmlprints:
(< 3 (+ 3 2))is Z3's prefix notation for3 < 3 + 2, which is the same logical claim as(3 + 2) > 3.
A broken loop invariant¶
- A loop invariant must hold before the first iteration and survive every execution of the loop body.
- The example below survives the first iteration but not the second.
- The initialization obligation is
0 <= 2, which holds. - The preservation obligation is:
- Z3 finds a counterexample to this implication:
i = 2.2 <= 2is true, so the invariant holds before the iteration.2 < 3is true, so the loop body runs once more.2 + 1 <= 2is false, so the invariant is broken after the iteration.
- Running
uv run frml check --trace examples/ex03_while_count_up_bad.frmlprints:
fn bad
--- invariant:5:17: loop invariant initially: (i <= 2)
prove: (<= 0 2)
=> VERIFIED
--- invariant:5:17: loop invariant preserved: (i <= 2)
given: (and (>= 2 i!1) (> 3 i!1))
prove: (>= 2 (+ i!1 1))
=> FAILED
- Adding
--examplemakes the CLI show the witness:
- The counterexample
i = 2does not meani <= 2is false wheniis2.- It means "if the loop reaches
i = 2, the next iteration violates the invariant".
- It means "if the loop reaches
- The loop can reach
i = 2along the way:0,1,2.- Then, because
2 < 3, it increments to3, and3 <= 2is false.
- Then, because
- The correct upper bound is
i <= 3, becauseireaches3before the loop exits.
A more complex example: abs¶
- Frml program:
- Entry
- There are no
requiresclauses, so the path starts empty. - Create a fresh integer symbol
x!1for the parameterx. - Put it in
state.vars["x"]. - Snapshot it into
state.old_vars["x"].
- There are no
- The
if- Evaluate the condition
x >= 0to the termx!1 >= 0. - Make two copies of the state.
- Then branch:
then_state.pathgetsx!1 >= 0.else_state.pathgetsnot (x!1 >= 0), which meansx!1 < 0.
- Evaluate the condition
- The first
return- Evaluate
xtox!1. - Evaluate the
ensuresclauseresult >= 0withresultreplaced byx!1. - Emit an obligation:
- Evaluate
- The second
return- Evaluate
-xto-x!1. - Emit an obligation:
- Evaluate
- For the first obligation, Z3 is asked:
- There is no value of
x!1that makes this true.- So Z3 returns
unsat. - The obligation is
VERIFIED.
- So Z3 returns
- The second obligation is:
- This is also
unsat, so the program isVERIFIED.
Translating expressions to Z3¶
The eval_expr method maps each Frml expression node to a Z3 term.
Literals¶
42becomesz3.IntVal(42).truebecomesz3.BoolVal(True)."hi"becomesz3.StringVal("hi").
Variables¶
xbecomes the term currently stored instate.vars["x"].resultbecomes the current return value term, only inside anensuresclause.
Operators¶
a and bbecomesz3.And(a, b).a or bbecomesz3.Or(a, b).a => bbecomesz3.Implies(a, b).!abecomesz3.Not(a).a + b,a - b,a * bbecome the matching Z3 operations.<,<=,>,>=become Z3 comparison terms.a == banda != bbecome equality and its negation.
A short example¶
- Frml:
0 <= i and i < n - Z3:
And(0 <= i, i < n) - The prover does not compute the expression.
- It builds a formula describing the expression.
Division and modulo add an obligation¶
- Division by zero is undefined.
- The verifier turns it into another proof obligation.
- When the prover evaluates
a / bora % b, it first emits:
- Then it returns the Z3 division or modulo term.
- Example:
- The
requiresclause putsb!1 != 0into the path. - The
return a / bemits an obligationb!1 != 0. - The path already contains that fact, so Z3 proves it immediately.
requires and ensures¶
- Contracts are the source of most hypotheses and goals.
requires is an assumption¶
- At function entry, every
requiresclause is evaluated. - The result is appended to the path.
- Later obligations may assume it.
ensures is a goal¶
- At every
return, everyensuresclause is evaluated. - The
resultplaceholder is replaced by the returned expression. - The result becomes a goal, checked against the current path.
Multiple clauses¶
- Multiple
requiresclauses are and'ed, one fact per clause in the path. - Multiple
ensuresclauses produce one obligation per clause. - Example:
fn max(a: Int, b: Int) -> Int
ensures result >= a
ensures result >= b
ensures result == a or result == b
{
if a >= b {
return a;
} else {
return b;
}
}
- The
ifproduces two end states… - …and there are three
ensuresclauses… - …so there are six postcondition obligations (three per branch):
- Then branch goals:
a >= a,a >= b,a == a or a == b. - Else branch goals:
b >= a,b >= b,b == a or b == b.
- Then branch goals:
- Each is proved with the appropriate branch condition in the path.
11. old(...)¶
old(e)means "the value ofeat the moment the function was entered".- The prover snapshots parameters at entry into
old_varsandold_arrays. - Inside an
ensuresclause,old(x)reads that snapshot, not the current value. - Example:
fn increment_first(a: Array<Int>)
requires length(a) > 0
ensures a[0] == old(a[0]) + 1
{
a[0] = a[0] + 1;
}
- At entry,
ais a fresh array symbola!1with some length.old_arrays["a"]keeps that originala!1.
- After the assignment,
state.arrays["a"]is the updated array.a[0]in the postcondition reads the updated array.old(a[0])reads the originala!1.
- The emitted postcondition goal looks like:
- Both sides simplify to the same value, so Z3 proves it.
Branching with if¶
- A conditional statement forks the symbolic execution.
- The condition is evaluated once.
- The
thenbranch continues with the condition added to the path. - The
elsebranch continues withnot conditionadded to the path.- If there is no
else, the fall-through branch still getsnot condition.
- If there is no
- The prover tracks a list of states, one per path through the code.
- Example:
- After the
if, there are two states:- State 1 path:
x >= 0. - State 2 path:
not (x >= 0).
- State 1 path:
- Each
returnis checked independently against its own path.
assert¶
- An
assertstatement produces a goal from the current path.
- Evaluate the condition.
- Emit an obligation with the current path as hypotheses and the condition as goal.
- If Z3 proves it, execution continues with the fact now guaranteed.
The prover does not add the assertion to the path after checking because
assertis a check, not an assumption. The programmer asserts what should already be true, so the verifier must prove it from what came before.
Arrays¶
Arrays need more machinery than integers.
Representation¶
- An array is a Z3
Array(Int, elem)plus a length.- The array maps integer indexes to element values.
- The length is a separate symbolic integer.
length(a)returnsarr.length, the symbolic length.a[i]becomesz3.Select(arr.term, i).- Also emits a bounds obligation
0 <= i and i < length(a).
- Also emits a bounds obligation
a[i] = vbecomesz3.Store(arr.term, i, v), a new array.- Also emits a bounds obligation for
i.
- Also emits a bounds obligation for
Array literals¶
[3, 4]becomes a chain ofStoreoperations:
- Its length is the literal
2.
Bounds obligations¶
- Every array read and write produces:
- Example from
increment_first:
Array equality¶
- Frml
a == bon two arrays is not a single Z3 equality.- The lengths must be equal.
- Every element in range must be equal.
- The prover builds:
- This is why array equality is defined by a quantified formula.
Loops and invariants¶
- Loops are the hardest part of verification, because a loop runs an unknown number of times.
- The prover cannot execute the loop symbolically "until it ends", so it uses invariants.
- A loop invariant is a fact that is true:
- before the first iteration…
- …after every iteration…
- …and therefore after the loop ends.
The count example¶
fn count(n: Int) -> Int
requires n >= 0
ensures result == n
{
let i: Int = 0;
while i < n
invariant 0 <= i
invariant i <= n
decreases n - i
{
i = i + 1;
}
return i;
}
- Verifying
countemits seven obligations in total:- Two initialization.
- Two preservation.
- Two termination.
- One final postcondition.
Initialization obligations¶
- Before the first iteration, each invariant must hold.
0 <= iwithi = 0becomes0 <= 0.i <= nwithi = 0becomes0 <= n, proved fromrequires n >= 0.
Preservation obligations¶
- This check is an inductive step over an arbitrary loop iteration, not just the first one.
- Havoc
i, turning it into a fresh symbol. - Assume the invariants and the loop condition:
0 <= i,i <= n, andi < n. - Run the body
i = i + 1, so the new value isi + 1. - Prove each invariant again for the new value:
0 <= i + 1follows from0 <= i.i + 1 <= nfollows fromi < n.
- Havoc
Exit state¶
- After the loop, the prover does not know how many iterations ran.
- It havocs every variable the loop body assigns.
- A havoced variable becomes a fresh symbol.
- It then assumes the invariants and
not condition.
- For
count, after the loop:ibecomes a fresh symbol.- The path gains
0 <= i,i <= n, andnot (i < n), soi >= n. - Together these force
i == n.
- The final
return ithen provesensures result == n.
Havoc¶
- Havoc is how the prover forgets what a loop did to a variable.
- Scan the loop body for assigned scalar names and array names.
- For each assigned scalar, replace its term with a fresh symbol of the same sort.
- For each assigned array, replace its term with a fresh array, keeping the same length.
- Why this is sound:
- The loop may run any number of times.
- We cannot know the exact final value of an assigned variable.
- We only know what the invariant tells us about it.
- The invariant is what survives the havoc.
- It is the only bridge from before the loop to after it.
Termination with decreases¶
- A
decreasesclause proves a loop terminates. - Two obligations are emitted.
Before the loop¶
- The measure must be non-negative:
After the body¶
- The measure must strictly decrease:
- For
count, the measure isn - i.- Entry:
n - 0 >= 0, proved fromrequires n >= 0. - After body, with the havoced
iand the invariant/condition facts assumed:n - (i + 1) < n - i, which simplifies to-1 < 0.
- Entry:
- The strict-decrease check uses the same havoced
ias the preservation check.
Function calls¶
- When one function calls another, the prover uses the callee's contract.
The model_call method¶
- For a call
f(args):- Prove the callee's
requiresclauses under the caller's current path. - For arrays the callee may write, create a fresh post-array.
- For read-only arrays and scalars, keep the caller's values.
- For the return value, create a fresh result symbol.
- Assume the callee's
ensuresclauses. - Add those assumptions to the caller's path.
- Prove the callee's
- The caller never sees the callee's body.
- It only sees the callee's contract.
Example¶
fn inc(x: Int) -> Int
requires x >= 0
ensures result == x + 1
{ return x + 1; }
fn main() -> Int
{
return inc(41);
}
- For the call
inc(41):- Emit a precondition obligation:
41 >= 0. - Create a fresh result
inc_result!N. - Assume
inc_result!N == 41 + 1. mainreturns that result, and the postcondition is proved.
- Emit a precondition obligation:
Array arguments¶
- A callee may mutate an array argument.
written_paramstracks which array parameters a function writes.- A written array gets a fresh post-array.
- A read-only array keeps the caller's array unchanged.
- After the call, the caller's array argument is updated to the callee's post-array.
Array returns¶
- A function may return an array, but only a fresh one:
- an array literal,
- a call to another array-returning function, or
- a function-local array variable.
- Returning an array parameter is rejected by the type checker.
- Otherwise the caller would hold two names for the same array, which reintroduces the aliasing the language avoids.
- At a call site, the returned array is a fresh
ArrayValwith an unknownlength >= 0, exactly like any other result. - The callee's
ensuresare assumed, and the well-formedness obligations inside them (array bounds, non-zero divisors) are suppressed:- They are part of what is being assumed, not something the caller proves.
19. Recursion¶
- Recursive functions need a
decreasesclause.
At function entry¶
- Emit
decreases >= 0. - For
factorial, that isn >= 0, proved fromrequires n >= 0.
At a recursive call¶
- The callee's
requiresclauses are proved at the call site. - For
factorial(n - 1), the prover provesn - 1 >= 0.
The strict-decrease check¶
model_callemitsdecreases_at_call < decreases_at_entryfor a self-call.- This applies to statement calls and to whole-right-hand-side calls with array arguments.
- For scalar self-calls inside expressions, the current implementation proves the callee's precondition but does not emit the strict-decrease obligation.
The factorial example¶
fn factorial(n: Int) -> Int
requires n >= 0
ensures result >= 1
decreases n
{
if n == 0 {
return 1;
} else {
return n * factorial(n - 1);
}
}
- The prover emits four obligations:
n >= 0for the entrydecreases n >= 0.1 >= 1for the base-case postcondition.n - 1 >= 0for the recursive call's precondition.n * factorial_result >= 1for the recursive postcondition.
20. Quantifiers¶
- Frml supports
forallandexistsin specifications.
- The prover translates these to Z3 quantifiers.
forallbecomesz3.ForAll([var], body).existsbecomesz3.Exists([var], body).- The quantified variable becomes a fresh symbol.
- The body is evaluated with that variable added to the state.
Why _quant_depth exists¶
- Bounds and division obligations are statements about "this program point".
- Inside a quantifier body there is no single program point.
- So the prover skips bounds and division obligations while
_quant_depth > 0. - The quantified formula itself is still a term, and Z3 reasons about it directly.
Array property example¶
- The
ensuresgoal is a Z3 term:
- Z3 knows how to decide quantified integer formulas like this one.
The Z3 check loop¶
- The
check_obligationsfunction is where Z3 is actually called.
solver = z3.Solver()
solver.set(timeout=timeout_ms)
for ob in obligations:
solver.push()
hyp = z3.And(*ob.hyp) if ob.hyp else z3.BoolVal(True)
solver.add(z3.Not(z3.Implies(hyp, ob.goal)))
result = solver.check()
if result == z3.unsat:
-> VERIFIED
elif result == z3.sat:
-> FAILED with the counterexample model
else:
-> UNKNOWN
solver.pop()
- Each obligation gets a fresh solver context via
pushandpop. - The empty hypothesis list becomes
True. - Z3 is asked to satisfy
not (hyp => goal). unsatmeans the claim holds.satmeans a counterexample exists.- Anything else is
unknown.
The three outcomes¶
VERIFIED¶
- Z3 found no counterexample.
- The claim is valid.
- Every obligation in the program reached this result.
FAILED¶
- Z3 found a counterexample model.
- The claim is false for some inputs.
- The CLI prints the obligation kind and description.
- Example:
- The obligation is:
- Z3 finds any value making
not (x > x)true, which is every value, soFAILED. frml check --exampleadds the concrete counterexample values to this output:
- When the refutation has a specific witness, it shows that instead. For example,
a loop invariant that only holds for the first iteration prints
i = 1.
UNKNOWN¶
- Z3 timed out or gave up.
- The verifier does not pretend the claim is proved.
- The program is not silently accepted.
- The verifier never reports
VERIFIEDfor a program it could not prove.
A mental model to keep¶
- The prover runs the program with formulas, not numbers.
- A variable is a symbol, not a value.
- The path is a list of facts known to be true.
- A contract clause is either an assumption or a goal.
- A loop invariant is a fact that survives an unknown number of iterations.
- Every obligation is
hypotheses => goal. - Z3 proves the obligation by showing the negation has no solution.
Appendix: Structure of prover.py¶
ArrayVal: an array term plus its length.State: the symbolic program state.Obligation: one verification condition.ProverResult: all obligations for one function.CheckOutcome: the Z3 result for one obligation.- Methods on
Prover:verify: loops over functions and collects obligations.verify_function: sets up entry state, snapshotsold, runs the body.exec_stmtsandexec_stmt: symbolic execution of statements.exec_while: invariant initialization, preservation, termination, and exit.model_call: uses a callee's contract at a call site.eval_expr: translates one expression into a Z3 term._emit: records one obligation._fresh: makes a fresh symbol name.
- Module-level functions:
check_obligations: runs the Z3 loop.verify_program: the entry point from the CLI.