The first post in this series introduced Frml,
a little procedural language I created
so that I could learn how program verification works.
The second explained how Frml checks simple scalar code;
this one explains how it checks programs with multiple functions.
As a reminder,
the scalar prover owns the engine and leaves two hooks for higher levels:
Contracts are the source of most hypotheses and goals.
At function entry,
every requires clause is evaluated,
the result is appended to the path,
and later obligations may assume it.
At every return,
every ensures clause is evaluated,
the result placeholder is replaced by the returned expression,
and the result becomes a goal checked against the current path.
Multiple requires clauses are and’ed,
one fact per clause in the path,
and multiple ensures clauses produce one obligation per clause.
For 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 if produces two end states,
and there are three ensures clauses,
so there are six postcondition obligations, three per branch.
The if branch goals are a >= a, a >= b, and a == a or a == b;
the else branch goals are b >= a, b >= b, and 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:
result_term supplies the term that result denotes inside the clause.
A Complete Trace
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);
}
verify_program builds the Prover and calls verify().
verify() sets current_fn = double and calls verify_function(double).
verify_function builds the entry state:
state.vars["x"] = Int("x!1"): a fresh integer symbol.
state.old_vars["x"] = Int("x!1"): the old(...) snapshot.
No requires, no decreases, so _assume_spec leaves the path [].
exec_stmt_seq([return x + x], state) starts with states = [state].
The single statement is return x + x, so exec_stmt calls visit_StmtReturn.
eval_rhs(x + x, state) falls through to eval_expr(x + x, state):
visit_ExprBinary sees the operator +.
The left x becomes Int("x!1"); the right x becomes Int("x!1").
It returns the Z3 term x!1 + x!1.
Back in visit_StmtReturn, value = x!1 + x!1.
_check_ensures runs for the single ensures result == x + x:
It calls check_postcondition, which evaluates result == x + x with
result_term = x!1 + x!1.
result looks up result_term, giving x!1 + x!1.
Each x looks up state.vars["x"], giving x!1.
The goal is the Z3 term (x!1 + x!1) == (x!1 + x!1).
One obligation is emitted with the empty path [].
visit_StmtReturn returns [], so states becomes [] and the loop breaks.
end_states == []; double has a return type and no fall-through path, so no
error is raised.
verify() records ProverResult("double", [the one obligation]).
verify_program hands the obligation to check_obligations, which asks Z3
to refute not (True => (x!1 + x!1) == (x!1 + x!1)). No value of x!1 makes
it true, so Z3 answers unsat and the obligation is VERIFIED.
old(...)
old(e) means “the value of e at the moment the function was entered”.
The prover snapshots parameters at entry into old_vars;
inside an ensures clause,
old(x) reads that snapshot rather than the current value.
For example:
fn bump(x: Int) -> Int
ensures result == old(x) + 1
{
x = x + 1;
return x;
}
At entry,
x is a fresh integer symbol x!1,
and old_vars["x"] keeps that original x!1.
After the assignment,
state.vars["x"] is the term x!1 + 1,
so x in the postcondition reads the updated value while old(x) reads the original x!1.
The emitted postcondition goal looks like:
x!1 + 1 == x!1 + 1
Both sides are the same term, so Z3 proves it.
Function Calls
When one function calls another, the prover uses the callee’s contract.
For a statement call f(args),
the prover proves the callee’s requires clauses under the caller’s current path,
assumes the callee’s ensures clauses,
and adds those assumptions to the caller’s path.
The caller never sees the callee’s body;
it only sees the callee’s contract.
For 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),
the prover emits a precondition obligation 41 >= 0,
creates a fresh result inc_result!N,
and assumes inc_result!N == 41 + 1.
main returns that result, and the postcondition is proved.
Recursion
Recursive functions need a decreases clause.
At function entry,
_assume_spec emits decreases >= 0.
For factorial,
that is n >= 0, proved from requires n >= 0.
At a recursive call,
the callee’s requires clauses are proved at the call site.
For factorial(n - 1), the prover proves n - 1 >= 0.
model_call emits decreases_at_call < decreases_at_entry for a self-call.
This applies to statement calls;
for scalar self-calls inside expressions,
such as the recursive call in factorial below,
the current implementation proves the callee’s precondition
but does not emit the strict-decrease obligation.
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 >= 0 for the entry decreases n >= 0,
1 >= 1 for the base-case postcondition,
n - 1 >= 0 for the recursive call’s precondition,
and n * factorial_result >= 1 for the recursive postcondition.
Quantifiers
Frml supports forall and exists in specifications:
forall i: Int :: 0 <= i and i < 3 => i >= 0
The prover translates these to Z3 quantifiers:
forall becomes z3.ForAll([var], body)
and exists becomes z3.Exists([var], body).
The quantified variable becomes a fresh symbol,
and the body is evaluated with that variable added to the state.
In the example above,
the variable i becomes a fresh symbol added to the state,
the body becomes the Z3 term Implies(And(0 <= i, i < 3), i >= 0),
and the whole quantifier becomes z3.ForAll([i], body).
Similarly,
if exists was used,
the variable i would again become a fresh symbol,
the body would become And(0 <= i, i < 3, i == 2),
and the whole quantifier would become z3.Exists([i], body).
Both forms can appear in an ensures clause:
fn f() -> Bool
ensures result == (forall i: Int :: 0 <= i and i < 3 => i >= 0)
{ return true; }
fn g() -> Bool
ensures result == (exists i: Int :: 0 <= i and i < 3 and i == 2)
{ return true; }
Coming up on the anniversary of being laid off,
I’m thinking about what I’d like to do differently in year 2:
Find people in the east end of Toronto who want to play some jazz once a week.
I’m currently commuting to the west end to do that:
I enjoy playing, but the travel time is wearying.
If you’re interested, I’m easy to reach.
Find a small F&SF writers’ group, also in the east end, to swap crits with.
I’m currently getting great feedback from a friend,
but would like more perspectives and an excuse to drink coffee.
(Scribophile has been moderately useful,
Wattpad hardly at all.)
Again,
if you’re interested, please reach out.
Go on long bike rides.
A friend has been going on 50km+ rides this year;
it’ll take me a long time (and probably a new bike) to get up to that,
but it’s a shame not to be out on sunny days like today.
I started working on CATMAID a few weeks ago;
between teaching and other commitments I haven’t been able to immerse myself yet,
but I’m hoping to do that in November.
On the upside,
fixing bugs in software that scientists use is pretty rewarding.
On the other hand,
it’s all remote work,
and I miss being able to grab a cup of coffee with colleagues in person.
Over (almost) 12 months I sent more than 60 job applications,
which led to four interviews and three offers
(one of which I now wish I’d taken).
I don’t think I’m going to send CVs to people I don’t know any more,
but if something interesting comes up in Toronto,
see the note above about coffee with colleagues.
I’m using Jon Udell’s Bram to experiment with AI coding,
mostly so that I won’t just be recycling other people’s talking points
in discussions about the subject.
I expect I’ll keep doing this,
and hope to start contributing to the tool itself.
I’ve nominated myself for a seat on the Carpentries board.
I dithered about this for a long time,
but in the end decided that I’d regret not doing it.
Let’s see how the voting goes…
I’d like to run some undergraduate projects next semester.
I have students lined up for one of the games,
but am still looking for people for the others.
As always, please reach out if you’re interested
(or if you have students who might be).
Most of all,
I’d like to be happier in the coming year than I have been in the one gone past.
Losing my job just a few weeks after my daughter left home for university
put me in a tailspin.
I’ve dealt with depression off and on for years;
the last twelve months weren’t as bad as some dips,
but the trough has lasted longer.
Let’s see what the next year brings.
The first post in this series introduced Frml,
a little procedural language I created
so that I could learn how program verification works.
This post explains how Frml checks scalar-level programs
that have straight-line and branching code over scalar values
with no function calls, contracts, loops, or quantifiers.
The Prover’s Data Model
The prover tracks a program point with a State object.
State.vars holds the symbolic values of scalar variables,
and State.path holds every fact assumed to hold so far.
The prover never stores concrete numbers in variables;
it stores Z3 formulas about numbers.
For example,
x might hold the symbolic integer x!1 rather than the number 5,
where x!1 means “the first fresh symbol named x”.
An Obligation records one claim to prove.
Its kind can be assert or division at this level,
its description is the human readable text of the claim,
hyp is the list of hypothesis terms,
goal is the goal term,
and pos is the source position of the claim
(shown in --trace output).
Fresh Names
The prover keeps a counter and calls _fresh(name)
to get name!N with an increasing N,
so x!1, x!2, and x!3 are different symbols
even though they all relate to the source variable x.
This matters because when x is reassigned,
the old x!1 is not overwritten;
the new value becomes a new symbol such as x!5,
which keeps the symbolic execution correct.
For example, after the statement:
x = x + 1;
the state maps x to the term x!1 + 1, not to a number.
Operation
The prover works in two phases:
it generates obligations by walking the AST and collecting Obligation objects,
then checks them by handing each obligation to Z3 and turning the answers into outcomes.
verify() is the generation phase for the whole program:
verify verifies one function at a time in source order;
self.obligations is reset per function so obligations stay grouped by function.
verify_function() handles one function:
defverify_function(self,fn):state=State()# Entry state: fresh constants for the parameters.forpinfn.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)iffn.return_typeisnotNone:ifend_states:raiseFrmlVerificationError(f"function {fn.name!r} has a path that does not return",fn.pos)else:forsinend_states:self._check_ensures(fn,s,None)
_assume_spec and _check_ensures do nothing at this language level
because there are no requires, ensures, or decreases clauses,
so the entry path is empty and the body’s fall-through is simply checked.
exec_stmt_seq returns the states of paths that fell off the end of the body.
A return produces no end state (see below),
so if a value-returning function has any end state,
some path never returned,
which is an error.
In exec_stmt_seq, states is the set of live paths,
starting with the single entry state,
and each statement maps every live state to zero or more successor states (see below).
exec_stmt calls stmt.accept(self, state),
which dispatches to visit_StmtAssign, visit_StmtIf, visit_StmtLet, and so on.
The “zero or more” matters because of 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,
since 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 (i.e., an if body) runs,
any variables introduced inside it are deleted from the resulting states,
so local declarations do not leak outward.
Expressions
Expressions use the same visitor pattern as statements:
expr.accept(...) calls visit_ExprBinary, visit_ExprVar, and so on.
Each visitor returns a Z3 term.
eval_expr never actually computes anything;
as noted above,
it translates a Frml expression into the Z3 formula that describes it
using the current symbolic state.
A Complete Trace
Let’s trace a complete verification:
fn main() -> Int
{
let x: Int = 3;
assert x > 0;
return 0;
}
verify_program builds the ScalarProver and calls verify().
verify() sets current_fn = main and calls verify_function(main).
verify_function builds the entry state: state.vars is empty (no
parameters), and the path is empty.
exec_stmt_seq([let, assert, return], state) starts with states = [state].
let x: Int = 3; evaluates the literal 3 and stores it in
state.vars["x"] as the Z3 integer 3 (not a fresh symbol).
At assert x > 0;, the prover evaluates the assertion expression:
x looks up the stored term 3.
> builds the Z3 term 3 > 0.
The prover emits one obligation:
kind: assert
description: assert (x > 0)
hypotheses: the current path (empty here)
goal: 3 > 0
return 0 drops the path, so end_states is empty and no error is raised.
verify() records ProverResult("main", [the one obligation]).
verify_program hands the obligation to check_obligations, which builds
the claim not (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 returns unsat,
and the obligation is VERIFIED.
uv run frml check --trace examples/scalar/ex01_assign_then_assert.frml prints:
Variables:
the variable x becomes the term currently stored in state.vars["x"].
Operators:
a and b becomes z3.And(a, b) and so on for other unary and binary operators.
Division by zero is undefined,
the verifier turns it into another proof obligation.
n the prover evaluates a / b or a % b, it first emits:
hypotheses: current path
goal: b != 0
and then returns the Z3 division or modulo term. For example:
fn main() -> Int
{
let x: Int = 10;
let y: Int = 2;
if y != 0 {
assert x / y == 5;
}
return 0;
}
The if y != 0 guard puts y != 0 on the path before the division. The
division emits y != 0, which the path already contains, so Z3 proves it
immediately.
Branching
A conditional statement forks the symbolic execution.
The condition is evaluated once,
then the then branch continues with the condition added to the path,
while the else branch continues with not condition added to the path.
(If there is no else, the fall-through branch still gets not condition.)
The prover tracks a list of states, one per path through the code.
Here is the code that does the forking:
An assert statement produces a goal from the current path:
assert 1 < 2;
The prover evaluates the condition, emits an obligation with the current path
as hypotheses and the condition as goal, and if Z3 proves it, execution
continues with the fact now guaranteed.
The prover does not add the assertion to the path after checking
because assert is a check, not an assumption.
The programmer asserts what should already be true,
so the verifier must prove it from what came before.
Each obligation gets a fresh solver context via push and pop.
The empty hypothesis list becomes True.
With no hypotheses, the claim is goal alone, i.e. True => goal.
The code writes z3.BoolVal(True) so z3.And(*ob.hyp) always has a value,
because Z3’s And() is not defined over an empty argument list.
Z3 is asked to satisfy not (hyp => goal).
unsat means the claim holds,
sat means a counterexample exists,
and anything else is unknown.
A few months ago I set out to learn how program verification works.
I tried Lean and Dafny,
but kept wondering how verification actually worked.
To find out,
DeepSeek and I built Frml,
which is a very simple imperative language with its own verifier.
This post walks through the Frml verification engine, explaining how a Frml
program becomes Boolean logic that the Z3 solver can check, and what Z3 does
with that logic.
The language and its verifier are layered: the scalar level
handles straight-line and branching code with no functions or loops,
while successive levels add features one by one.
Frml is aimed at experienced programmers: you should understand basic
Boolean logic (and, or, not, implication) and already know what a lexer,
parser, and abstract syntax tree (AST) are, but you need no prior background in
formal verification.
The big picture
Frml’s lexer and parser turn program text into an AST, and the typechecker rejects
programs with type errors. The prover then turns the AST into verification
conditions of the form:
hypotheses => goal
The prover asks Z3 to confirm every verification condition, and if Z3 confirms
all of them, the program is verified.
OK, so what’s a verifier?
To start,
a proof obligation is a claim:
from these hypotheses, this goal always follows
Symbolically:
h1 and h2 and ... and hn => goal
The h1 ... hn are facts known at some point in the program, and goal is a
fact that must hold at that point. Example claims from a real program are
“knowing x >= 0, prove 0 <= x”, “knowing i < n, prove i + 1 <= n”, and
“knowing nothing, prove x > x”, where the last one is false. The prover’s job
is to produce claims in this shape.
What Z3 is and what it does
Z3 is an SMT solver, where SMT means Satisfiability Modulo Theories. It knows
the theory of integers, arrays, strings, and Boolean logic. A formula is
satisfiable when some assignment of values to its variables makes it true.
Given a formula, Z3 returns one of three answers: sat, meaning a satisfying
assignment exists and Z3 can show you one; unsat, meaning no satisfying
assignment exists; or unknown, meaning Z3 gave up or timed out. An example
that can be satisfied:
formula: x > 3 and x < 5
answer: sat (x = 4 works)
An example that cannot:
formula: x > 3 and x < 4
answer: unsat (no integer fits)
From “is it valid?” to “is it unsatisfiable?”
The prover wants to prove that hypotheses => goal is valid, meaning true
for every possible assignment of the variables. Z3 does not directly check
validity; it checks satisfiability. The two are connected, because A => B
is valid exactly when not (A => B) is unsatisfiable. So the prover asks Z3
about:
not (hypotheses => goal)
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, and
everything else is just building the hypotheses and goals, for a rather large
value of “just”.
A, B, and C do not have specific values. Each instead represents the set
of possible Boolean values, so we can specify constraints like A == B.
solver=Solver()solver.add(A==B)solver.add(B==C)report("A == B & B == C",solver.check())
We can then ask Z3 to find a model that satisfies those constraints:
A == B & B == C: sat
A False
B False
C False
To see unsatisfiability, require A to equal B and B to equal C but A
and C to 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())
A == B & B == C & B != C: unsat
The next post in this series will show how Frml builds proof obligations
for simple programs that manipulate scalar variables without loops or conditionals.
Earlier this week I asked a question on Mastodon:
“Who are the Jane Goodalls and David Attenboroughs of software?”
Goodall was a primatologist best known for
decades of research on chimpanzees,
and Attenborough has been making nature documentaries
longer than I’ve been alive.
Both are well-known to the general public
(at least in the English speaking world)
and have been powerful forces for good.
Which means that the answer to my question is,
“We don’t have any.”
If you ask someone who isn’t in tech to name people who are,
they will say Gates or Musk or Zuckerberg.
They know our corporate super-villains,
not the people who have been trying to make the world a better place.
It’s as if the only people you knew who had something to do with the environment
were the presidents of oil companies,
none of whom I could name without doing an online search.
I don’t know why this is,
how to change it,
or whether changing it would matter.
Now that I’ve noticed it, though,
I can’t stop thinking about it.
If there’s someone outside the Anglosphere who fits the bill,
I’d be grateful for a pointer.
Mission-driven startups fail in predictable ways every year
because good intentions are no substitute for a theory of change,
money,
and community involvement.
As I found out the hard way,
“At least we’re trying to do good” is the most dangerous sentence in social entrepreneurship,
because it lets founders skip the boring, specific work
that decides whether a venture actually helps anyone.
I wrote the first version of this almost twenty years ago
for undergraduate students starting their first socially-conscious nonprofit or mission-driven company.
I hope this update is still useful, and would welcome feedback.
Tip 1: Fall in love with your mission, not the actual problem.
Solve the problem you want to solve, not the one the community says it has.
The people you’re “helping” don’t need to be consulted:
you already know what’s best for them,
so skip community interviews and design the solution in the dorm room.
Start from the solution that excites you (“an app to raise awareness!”)
and hunt for a problem to bolt onto it.
Tip 2: Assume that good intentions guarantee good outcomes.
Don’t bother developing a theory of change:
if your heart is in the right place, the intervention will work.
Never check whether anyone already tried this and how it turned out.
Doing research on existing nonprofits and government programs will just slow you down.
Launch without a baseline:
if you never measure “before,” every “after” looks like success.
Tip 3: Choose a team that shares your passion and nothing else.
Recruit friends who care deeply about the cause
but have never balanced a budget, recruited other people, or shipped software.
Make sure no one on the team has ever been the person you’re trying to help:
that keeps your perspective pure and your assumptions untouched.
Never write down roles, ownership, or decision rights:
passion is all the governance structure you need.
Tip 4: Treat the people you serve as an afterthought.
Build for funders, donors, and demo-day judges instead of the people you’re helping.
After all, they’re the ones with the money (and grades).
Confuse “users” with “beneficiaries.”
Optimize the product for the people paying, not the people needing.
Involve the community only in the launch photo, not in the decisions.
Tip 5: Never charge, because it’s a good cause.
Confuse profit with greed:
after all, charging money for something socially valuable is selling out.
Run on donations, grants, and volunteers forever,
so you’re permanently one funding cycle away from death.
Underpay your own team in the name of the mission.
Passion is a substitute for a salary as well as for governance.
Tip 6: Measure what’s easy and flattering.
Count outputs like meals served, workshops held, and downloads
instead of trying to assess the actual change in people’s lives.
Collect warm, unverifiable “impact” numbers for the pitch deck (“tens of thousands of lives touched!”).
Cherry-pick the one success story and ignore the users who quietly stopped showing up.
Tip 7: Chase the grant money wherever it leads.
Write the mission to fit whatever funding call is open this month.
Pivot the program when the funder’s priorities change.
Say yes to any funder with money, even if their conditions quietly rewrite what you do.
Confuse “we won a grant” with “we’re making progress”.
Remember: the application is the deliverable.
Tip 8: Ignore every organization already working on this.
Treat other nonprofits and social enterprises as competitors to beat, not collaborators.
In particular, refuse to share information or coordinate events.
Duplicate an existing, proven program rather than partner with it,
because yours will obviously be better.
Never check what local government, community groups, or researchers are already doing.
Tip 9: Skip the boring structure and compliance.
Don’t decide between nonprofit, B-corp, and LLC until someone forces you,
and don’t bother to understand the trade-offs when you do.
Ignore safeguarding, data ethics, and informed consent:
a good cause doesn’t need paperwork.
Don’t bother with an impact-measurement framework or independent evaluation.
If “we know it works” is good enough for billion-dollar AI companies, it’s good enough for you.
Tip 10: Believe a well-meaning app can fix a structural problem.
Treat poverty, hunger, or injustice as a UI problem.
The right tool will fix what laws, markets, and power created.
Ignore root causes and who benefits from the status quo.
An app that “raises awareness” is a complete theory of change.
Assume the problem exists because nobody has thought of a clever solution yet,
not because someone benefits from keeping it broken.
Tip 11: Let moral weight burn you out.
Tell yourself the people you serve can’t afford for you to rest.
Skip sleep, meals, classes, and showers.
Wear burnout as a badge of honor.
If you’re not miserable, you don’t care enough.
Scale to new communities before you’ve proven the thing works in one, because urgency excuses everything.
Tip 12: Never learn to sell, because selling is for profiteers.
Avoid asking for money, partners, or allies.
Fundraising is icky: the mission should speak for itself.
Never build a real advisory board.
Instead, look for people who will confirm what you’ve already decided
rather than raise money or spot problems.
Actually…
Every one of these is a documented way socially-conscious ventures actually die.
Reverse the list and you have the real advice:
Fall in love with the problem the community names, and design with them, not for them.
Build a theory of change and study what’s already been tried.
Assemble complementary skills, and include people with lived experience of the problem.
Serve the people you’re helping first; let funders follow.
Earn revenue or build durable funding so the mission outlives the grant.
Measure outcomes, honestly, with a baseline.
Let the mission drive funding, not the other way around.
Map the ecosystem and partner rather than duplicate.
Choose the right legal form and take safeguarding and compliance seriously.
Attack root causes and respect the systems you’re working within.
Protect your own sustainability — a burned-out founder helps no one.
Learn to fundraise, build a real board, and make the organization outlast you.
Autumn always makes me feel like I ought to be back in school,
either to learn or to teach.
In turn,
that makes me think about student projects I would like to run.
Tools
Bram
Bram (“Bram runs agents mindfully”) is a desktop app that embeds
Claude Code and Codex in a GUI that provides structured, audited workflows.
This project will add several new features, including support for other
families of models such as DeepSeek.
A Little Program Verifier
Where the previous project would build a simple editor, this project would
build a verifier for a very simple programming language in order to teach
people how those tools work. (See here for preliminary work.)
XKCD Charts
Chart.xkcd creates charts in the hand-drawn style of
XKCD. This project will fix outstanding issues, add new features
such as axis limits and stable coloring schemes, and create wrappers in one
or both of Gleam or Dafny.
An I/O Library for Dafny
I want to translate Software Design by Example in Python into
Dafny to see and show how verifiability changes the way we build
software tools, but Dafny’s I/O and systems programming libraries don’t
offer what the examples need. This project will build those libraries,
starting with what’s listed in this blog post.
Games
Stardew Valley Murders
A mod that turns Stardew Valley into a murder mystery game.
Grow vegetables, then trade them for gossip and clues until you figure out
who murdered Clint and why.
This project now has a team for Winter 2027.
Rewind
A first-person shooter with a science-fiction theme in which each player has
a limited “temporal battery” that can be spent to reverse the flow of time
by a few seconds. If you just got shot, rewind and take cover instead; if
you missed a shot, rewind and aim lower. The twist is that your opponent
knows you can do this, turning every rewind into a battle of wits.
Save the Humans!
The zombies have attacked. The people at the zoo have panicked, and it’s up
to the animals to save them in this tongue-in-cheek game. The tiger can
chase people away from danger, but they might run straight into the arms of
the ravenous horde. The bunny is so cute that people will chase it, but if
they catch it they’ll just stand there petting it until they’re eaten, and
so on.
Tower Support Game
A tower defense game is one in which the player builds
fixed defenses against incoming waves of attackers. The objective of this
game is to prototype a simple tower support game, in which the player
builds bridges, first aid stations, and so on to help travelers reach their
destination.
Last winter I wrote a discrete event simulation framework in Python
to keep myself busy,
have something to put on my CV,
experiment with LLM-assisted coding,
and learn how async and await actually work.
Over the summer,
for similar reasons,
I played around a bit with Lean and Gleam,
thinking that I might translate the Software Design by Example books
from JavaScript and Python into one or the other.
Lean defeated me,
and I quickly grew frustrated with the gaps in Gleam’s standard library,
but my noodling around left me wanting to learn more about formal verification of programs.
After poking around a bit I decided to give Dafny a try,
only to find that its standard library has gaps too.
Depending on whether or not I’m able to find work,
I might try to recruit some undergrads to fill those in.
What follows is a spec for what I would need them to build
in order to be able to replicate the examples in the existing books.
In brief:
Dafny’s standard library provides only whole-file I/O (Std.FileIO) and JSON (Std.JSON).
Std.FileIO reads or writes an entire file in a single call;
there are no functions to open and close file handles and no streaming reads.
It also has no networking, hashing, SQLite, CSV/YAML, temporary-file, or path-manipulation support.
Standard streams
sys.stdin: missing
sys.stdout exists (kind of: print writes to standard output)
sys.stderr: missing
File operations
open(path, mode) with text and binary modes ("r", "w", "rb"): missing
file.close(): (obviously) also missing
file.read() to read a whole file exists:
Std.FileIO.ReadUTF8FromFile (text) or Std.FileIO.ReadBytesFromFile (bytes)
file.read(n) to read fixed-size blocks: missing
file.readlines() to read content as lines: missing
file.write(text) to save data to file exists:
Std.FileIO.WriteUTF8ToFile (text) or Std.FileIO.WriteBytesToFile (bytes)
Path manipulation
Path(...) (string to path): missing (paths are plain strings passed to Std.FileIO)
Path.read_text(): use Std.FileIO.ReadUTF8FromFile
Path.write_text(): use Std.FileIO.WriteUTF8ToFile
Path.mkdir() (including parents=True, exist_ok=True):
partial: Std.FileIO.WriteUTF8ToFile/WriteBytesToFile create nonexistent parent directories,
but there is no standalone mkdir
json.load() exists: Std.JSON.API.Deserialize (from seq<byte>, not from a file or string)
json.dump() exists: Std.JSON.API.Serialize (to seq<byte>; SerializeAlloc returns an array<byte>)
CSV
csv.reader() missing
csv.writerow() missing
csv.writerows() missing
csv.DictReader() missing
YAML
yaml.load(): missing
Binary records
struct.pack(): missing
struct.unpack(): missing
struct.calcsize(): missing
Hashing
hashlib.sha256().hexdigest(): missing (the standard library has Std.Base64, but no SHA/MD5)
hashlib.md5() with .update() for streaming hash: missing
SQLite
sqlite3.connect(): missing
connection.execute(): missing
connection.fetchall(): missing
connection.commit(): missing
TCP sockets
socket.socket(): missing
socket.gethostbyname(): missing
Missing from client side: .connect(), .send(), .sendall(), .recv(), .close()
Missing from server side: .bind(), .listen(), .accept(), .recv(), .send(), .close()
TCP server framework
A way to create a TCP server (e.g., a base class)
And then self.request.recv(), self.request.sendall(), self.client_address, server.serve_forever()
HTTP server
A way to create an HTTP server
And then do_GET() (with self.path and self.command), self.send_response(), self.send_header(), self.end_headers(), self.wfile.write(body), HTTPStatus enum
HTTP client
requests.get() (or a similar workhorse) with req.add_header()
And then a response with .status_code, .headers[], .text, .read()