The Scalar Level: Straight-Line and Branching Code¶
This tutorial explains how Frml checks scalar-level programs: straight-line
and branching code over scalar values (Int, Bool, String), with no
function calls, contracts, loops, or quantifiers. The engine is in
frml/prover_scalar.py; the shared records (State, Obligation,
ProverResult) live in frml/prover_types.py, model rendering in
frml/z3render.py, and the Z3 check loop in frml/z3check.py.
The prover's data model¶
- The prover tracks a program point with a
Stateobject.State.vars: symbolic values of scalar variables.State.path: every fact assumed to hold so far.
- The prover never stores concrete numbers in variables: it stores formulas about numbers.
- 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" (see below).
- An
Obligationrecords one claim to prove.- Its
kindcan beassertordivisionat this level. description: human readable text of the claim.hyp: the list of hypothesis terms.goal: the goal term.pos: the source position of the claim, shown in--traceoutput.
- Its
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.
The driver: who calls what¶
The CLI's check command eventually calls the module-level entry point:
def verify_program(program, timeout_ms=10000, trace=False):
prover = ScalarProver(program)
results = prover.verify()
outcomes = []
for r in results:
outcomes.extend(check_obligations(r.obligations, timeout_ms, trace=trace))
return results, outcomes
The prover works in two phases:
- Generate: walk the AST and collect
Obligationobjects. - Check: hand each obligation to Z3 and turn the answers into outcomes.
verify() is the generation phase for the whole program:
def verify(self):
results = []
for fn in self.program.functions:
self.obligations = []
self.current_fn = fn
self.verify_function(fn)
results.append(ProverResult(fn.name, self.obligations))
self.obligations = []
return results
verifyverifies one function at a time, in source order.self.obligationsis reset per function, so obligations stay grouped by function.
verify_function() handles one function:
def verify_function(self, fn):
state = State()
# Entry state: fresh constants for the parameters.
for p in fn.params:
state.vars[p.name] = self._fresh_scalar(p.type, p.name)
# Snapshot for `old(...)` (unused at this level; higher levels read it).
state.old_vars = dict(state.vars)
self._assume_spec(fn, state)
# Run the body. Surviving states reached the end without `return`; for
# a value-returning function that is an error.
end_states = self.exec_stmt_seq(fn.body, state)
if fn.return_type is not None:
if end_states:
raise FrmlVerificationError(
f"function {fn.name!r} has a path that does not return", fn.pos
)
else:
for s in end_states:
self._check_ensures(fn, s, None)
_assume_specand_check_ensuresdo nothing at this level because there are norequires,ensures, ordecreasesclauses, so the entry path is empty and the body's fall-through is simply checked.exec_stmt_seqreturns the states of paths that fell off the end of the body. Areturnproduces no end state (see below), so if a value-returning function has any end state, some path never returned, which is an error.
The statement loop¶
Statements are handled by three related methods:
def exec_stmt_seq(self, stmts, state):
states = [state]
for stmt in stmts:
new_states = []
for s in states:
new_states.extend(self.exec_stmt(stmt, s))
states = new_states
if not states:
break
return states
def exec_block(self, stmts, state):
before_vars = set(state.vars)
states = self.exec_stmt_seq(stmts, state)
for s in states:
for name in list(s.vars):
if name not in before_vars:
del s.vars[name]
return states
def exec_stmt(self, stmt, state):
return stmt.accept(self, state)
exec_stmt_seq handles the statement list:
statesis the set of live paths, starting with the single entry state.- Each statement maps every live state to zero or more successor states (see below).
exec_stmtcallsstmt.accept(self, state), which dispatches tovisit_StmtAssign,visit_StmtIf,visit_StmtLet, and so on.- "Zero or more" matters because of
return:
def visit_StmtReturn(self, stmt, state):
value, state = self.eval_rhs(stmt.expr, state)
self._check_ensures(self.current_fn, state, value)
return []
A return returns the empty list. new_states.extend([]) adds nothing, so that
path is dropped from states: execution stops there, exactly as it does at run
time. This is also what makes the verify_function fall-through check work:
only paths that reach the end of the body survive in end_states.
exec_block is exec_stmt_seq plus scoping. After a nested block (an if body)
runs, any variable introduced inside it is deleted from the resulting states,
so local declarations do not leak outward.
The expression loop¶
Expressions use the same visitor pattern as statements:
def eval_expr(self, expr, state, *, use_old=False, result_term=None):
return expr.accept(self, state, use_old=use_old, result_term=result_term)
expr.accept(...) calls visit_ExprBinary, visit_ExprVar, and so on. Each
visitor returns a Z3 term. eval_expr never actually computes anything; it
translates a Frml expression into the Z3 formula that describes it, using the
current symbolic state.
A complete trace: ex01¶
Let's trace a tiny function through the whole machine:
Step by step:
verify_programbuilds theScalarProverand callsverify().verify()setscurrent_fn = mainand callsverify_function(main).verify_functionbuilds the entry state:state.varsis empty (no parameters), and the path is empty.exec_stmt_seq([let, assert, return], state)starts withstates = [state].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: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:
return 0drops the path, soend_statesis empty and no error is raised.verify()recordsProverResult("main", [the one obligation]).verify_programhands the obligation tocheck_obligations, which builds the claimnot (True => 3 > 0)and asks Z3 to satisfy it (see "The Z3 check loop" below).- Z3 finds no assignment that makes
not (3 > 0)true, so it returnsunsat, and the obligation isVERIFIED.
uv run frml check --trace examples/scalar/ex01_assign_then_assert.frml prints:
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"].
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.
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
if y != 0guard putsy != 0on the path before the division. - The division emits
y != 0, which the path already contains, so Z3 proves it immediately. uv run frml check --trace examples/scalar/ex04_if_division.frmlshows both thedivisionandassertobligations verified.
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.
Here is the code that does the forking:
def visit_StmtIf(self, stmt, state):
cond, state = self.eval_rhs(stmt.cond, state)
then_state = state.copy()
then_state.path.append(cond)
then_ends = self.exec_block(stmt.then, then_state)
ends = list(then_ends)
if stmt.else_ is not None:
else_state = state.copy()
else_state.path.append(z3.Not(cond))
ends.extend(self.exec_block(stmt.else_, else_state))
else:
else_state = state.copy()
else_state.path.append(z3.Not(cond))
ends.append(else_state)
return ends
state.copy()duplicates the state so the two branches do not share a path list.- The
thenbranch appends the condition; theelsebranch appends its negation. exec_blockruns each branch and returns its end states, which are concatenated.- With no
else, the fall-through branch is just the state withnot condappended, and no statements to run. visit_StmtIfreturns the union of the two branches' end states — this is exactly how one state becomes two insideexec_stmt_seq.
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.
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.- With no hypotheses, the claim is
goalalone, that isTrue => goal. - The code writes
z3.BoolVal(True)soz3.And(*ob.hyp)always has a value; Z3'sAnd()is not defined over an empty argument list. - Logically,
True => goalis equivalent togoal, which is exactly what an obligation with no assumptions means.
- With no hypotheses, the claim is
- 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 goal is
3 < 0, which Z3 refutes, soFAILED. uv run frml check examples/scalar/ex03_assert_failure.frmlprintsFAILED.frml check --exampleadds the concrete counterexample values to this output.
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.
Next¶
The scalar level has no way to package and reuse a proof: every function is
verified in isolation, and nothing can call anything else. The contracts
level adds contracts and function calls on top of this
engine.