The Contracts Level: Functions and Contracts¶
This tutorial explains how Frml checks contracts-level programs: everything in
the scalar level, plus multiple functions,
requires/ensures contracts, function calls, old(...), recursion with
decreases, and quantifiers. The implementation is in frml/prover_contracts.py,
which subclasses the scalar prover (frml/prover_scalar.py).
If you have not read the scalar tutorial, start there: this page assumes the
data model, the exec_stmt_seq/eval_expr visitor loops, and the Z3 check loop
are already familiar.
How contracts extends scalar¶
The scalar prover owns the engine and leaves two hooks for higher levels:
def verify_function(self, fn):
state = State()
for p in fn.params:
state.vars[p.name] = self._fresh_scalar(p.type, p.name)
self._assume_spec(fn, state)
end_states = self.exec_stmt_seq(fn.body, state)
if fn.return_type is not None:
if end_states:
raise FrmlVerificationError(...)
else:
for s in end_states:
self._check_ensures(fn, s, None)
At the scalar level _assume_spec and _check_ensures do nothing. The contracts
prover overrides them:
class Prover(ScalarProver):
def _assume_spec(self, fn, state):
for req in fn.requires:
state.path.append(self.eval_expr(req, state))
if fn.decreases is not None:
d = self.eval_expr(fn.decreases, state)
self.entry_decreases = d
self._emit(
"decreases",
f"decreases {fn.decreases.render()} >= 0",
state.path,
d >= 0,
fn.decreases.pos,
)
def _check_ensures(self, fn, state, result):
for ens in fn.ensures:
self.check_postcondition(ens, state, result)
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):
ifbranch goals:a >= a,a >= b,a == a or a == b.elsebranch goals:b >= a,b >= b,b == a or b == b.
- Each is proved with the appropriate branch condition in the path.
check_postcondition turns an ensures clause into one obligation:
def check_postcondition(self, ens, state, result):
goal = self.eval_expr(ens, state, result_term=result)
self._emit("postcondition", ens.render(), state.path, goal, ens.pos)
result_termsupplies the term thatresultdenotes inside the clause.
A complete trace: double¶
Let's trace a tiny function through the whole machine:
fn double(x: Int) -> Int
ensures result == x + x
{
return x + x;
}
fn main() -> Int
{
return double(4);
}
Step by step:
verify_programbuilds theProverand callsverify().verify()setscurrent_fn = doubleand callsverify_function(double).verify_functionbuilds the entry state:state.vars["x"] = Int("x!1"): a fresh integer symbol.state.old_vars["x"] = Int("x!1"): theold(...)snapshot.- No
requires, nodecreases, so_assume_specleaves the path[].
exec_stmt_seq([return x + x], state)starts withstates = [state].- The single statement is
return x + x, soexec_stmtcallsvisit_StmtReturn. eval_rhs(x + x, state)falls through toeval_expr(x + x, state):visit_ExprBinarysees the operator+.- The left
xbecomesInt("x!1"); the rightxbecomesInt("x!1"). - It returns the Z3 term
x!1 + x!1.
- Back in
visit_StmtReturn,value = x!1 + x!1. _check_ensuresruns for the singleensures result == x + x:- It calls
check_postcondition, which evaluatesresult == x + xwithresult_term = x!1 + x!1. resultlooks upresult_term, givingx!1 + x!1.- Each
xlooks upstate.vars["x"], givingx!1. - The goal is the Z3 term
(x!1 + x!1) == (x!1 + x!1). - One obligation is emitted with the empty path
[].
- It calls
visit_StmtReturnreturns[], sostatesbecomes[]and the loop breaks.end_states == [];doublehas a return type and no fall-through path, so no error is raised.verify()recordsProverResult("double", [the one obligation]).verify_programhands the obligation tocheck_obligations, which asks Z3 to refutenot (True => (x!1 + x!1) == (x!1 + x!1)). No value ofx!1makes it true, so Z3 answersunsatand the obligation isVERIFIED.
old(...)¶
old(e)means "the value ofeat the moment the function was entered".- The prover snapshots parameters at entry into
old_vars. - Inside an
ensuresclause,old(x)reads that snapshot, not the current value. - Example:
- At entry,
xis a fresh integer symbolx!1.old_vars["x"]keeps that originalx!1.
- After the assignment,
state.vars["x"]is the termx!1 + 1.xin the postcondition reads the updated value.old(x)reads the originalx!1.
- The emitted postcondition goal looks like:
- Both sides are the same term, so Z3 proves it.
Function calls¶
- When one function calls another, the prover uses the callee's contract.
The model_call method¶
- For a statement call
f(args):- Prove the callee's
requiresclauses under the caller's current path. - 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:
Recursion¶
- Recursive functions need a
decreasesclause.
At function entry¶
_assume_specemitsdecreases >= 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.
- For scalar self-calls inside expressions (such as the recursive call in
factorialbelow), 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.
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.
forall example¶
- The variable
ibecomes a fresh symbol, added to the state. - The body becomes the Z3 term
Implies(And(0 <= i, i < 3), i >= 0). - The whole quantifier becomes
z3.ForAll([i], body).
exists example¶
- The variable
iagain becomes a fresh symbol. - The body becomes
And(0 <= i, i < 3, i == 2). - The whole quantifier becomes
z3.Exists([i], body).
Both forms can appear in an ensures clause: