writ checks plain Clojure against a spec of what the code is for. The spec is the problem statement written so a machine can check it: signatures for the public functions, and laws that say what their results mean. It lives in its own namespace, and the code never mentions writ.
It is built for code an LLM writes. The spec is the intent: the problem's states, the steps between them, and what each step means. An agent, or a person, writes it first; then the implementation. writ is the gate between them. Its report says what to fix: the rule that was broken, or the law that failed, the smallest input that fails it, and the value each side produced.
A spec is not a test suite. Tests pin down examples: this input gives that output. A spec states what must hold for every input: the output is ordered, and it has the same elements as the input. That is the meaning of a sort, not a sample of it. writ also checks the spec itself. It rejects a law that any implementation would satisfy, and it reports a function the laws don't pin down, naming a trivial stand-in that would pass.
The static rules come from Bend: pure code only, recursion that provably terminates, definitions in order, types that fit. Behaviour is checked two ways. The laws run, through test.check, on inputs generated from the spec's types. And writ proves them from the code: by rewriting and induction, or by running the code on symbolic values and handing the result to a solver. A proof holds for every input, not the ones a test happened to try. Every proof is replayed by a small checker before it is reported, and the solver's answers come with certificates that checker verifies, so the search is never trusted. The report says which laws are proved and which are only tested, and a spec can demand proof.
writ runs on jolt.
src/my/sort.clj the implementation: plain Clojure, no writ
test/my/sort_spec.clj the contract: spec, graph, refine, ann, law
test/my/sort_proof.clj optional: lemmas and hints that help the prover
test/my/sort_test.clj runs the check
- Declare the state graph. Every spec has one: the problem's states, as types -- refinements most often -- and the fns that step between them. It is the first thing a spec says. writ proves each of its edges, and checks that the code takes every step the graph names.
- Say how the steps are wired:
flowfor the path data takes through a fn,callsfor the layers it goes through. - State what each step means, as laws, and ask for proof with
(spec my.sort {:require :proved}). - Read the plan back:
(spec/plan 'my.sort-spec)prints the states, steps, signatures, laws and wiring from the spec alone, for a person to confirm before any code is written. - Write the implementation as ordinary Clojure.
- Run the check. If it fails, the report says what to fix. If a law holds but is not proved, help the prover with a lemma or a hint in the proof namespace; don't weaken the law.
- Keep the check in the test suite, so it gates every change.
writ is a test dependency. The spec and the check live on the test classpath, so production code never loads writ.
Note the quoting. (spec my.sort) is a macro inside the spec, so the
target namespace goes in unquoted. (spec/check 'my.sort-spec) is an
ordinary fn call, so the spec namespace it checks is quoted.
;; deps.edn
{:aliases
{:test {:extra-paths ["test"]
:extra-deps {io.github.jlt-commons/writ
{:git/url "https://github.com/jlt-commons/writ"
:git/sha "<sha>"}}}}}The implementation:
(ns my.sort)
(defn insert [x xs]
(if (seq xs)
(if (<= x (first xs))
(cons x xs)
(cons (first xs) (insert x (rest xs))))
(list x)))
(defn isort [xs]
(if (seq xs)
(insert (first xs) (isort (rest xs)))
()))The spec:
(ns my.sort-spec
(:require [writ.spec :refer [spec ann law graph refine]]))
(spec my.sort {:require :proved})
(ann insert [Nat (List Nat) -> (List Nat)])
(ann isort [(List Nat) -> (List Nat)])
(defn ascending? [xs]
(or (empty? xs) (apply <= xs)))
;; what makes a list sorted
(refine Sorted [xs (List Nat)] (ascending? xs))
;; the problem's states: a list goes in, a sorted list comes out, and
;; inserting into a sorted list keeps it sorted (`_` is where the state goes)
(graph sorting
{:states {:unsorted (List Nat), :sorted Sorted}
:edges {:unsorted {[isort] #{:sorted}}
:sorted {[insert Nat _] #{:sorted}}}})
(defn occurrences [x xs]
(count (filter #(= x %) xs)))
;; the graph already says a sort puts its input in order; it also
;; keeps every element, duplicates included
(law permutation (forall [x Nat, xs (List Nat)]
(= (occurrences x (isort xs)) (occurrences x xs))))
;; and insert adds exactly one x
(law insert-adds (forall [x Nat, xs (List Nat)]
(= (occurrences x (insert x xs)) (inc (occurrences x xs)))))The check:
(ns my.sort-test
(:require [clojure.test :refer [deftest is]]
[writ.spec :as spec]))
(deftest sort-meets-its-spec
(let [r (spec/check 'my.sort-spec)]
(is (:ok r) (:message r))))The graph's edges are laws: sorting:unsorted:isort says every list
isort returns is Sorted, and sorting:sorted:insert that insert
keeps a sorted list sorted. Each step the graph names must also be taken,
by some input. If insert dropped a value equal to the head, the report
would read:
writ.spec: my.sort-spec against my.sort: FAILED
law `permutation` fails for
x = 9
xs = [9 9]
(occurrences x (isort xs)) => 1
(occurrences x xs) => 2
(shrunk from {x 9, xs [0 6 13 8 10 19 9 4 11 9 4 5 9 9 13 2 18]}, failing on test 20)
(replay with {:seed 42})
A static failure stops the check before any law runs:
Writ: recursive call to `isort` does not descend: no argument is a
structurally smaller part of its own parameter (destructure it, or use
dec/rest/next of it under a test)
Start from the problem, not the code. Ask what makes an answer right, and write that down in the problem's own terms.
- Characterise the result. A sort's output is ordered and is a permutation of its input. Together those two laws are the whole meaning of sorting, and nothing else satisfies both.
- Compare with a model. When there is an obvious reference, say the
code agrees with it: a search tree built from
xslists(sort (distinct xs)). The model can be slow or naive, because it only runs in the check. - Relate operations. Say what the pieces do together: decoding an
encoded value gives the value back,
insertadds exactly one element,popundoespush. - Keep invariants. Say what every operation preserves: an ordered list stays ordered, a balanced tree stays balanced.
- Use the spec's own vocabulary. Measure results with helpers defined
in the spec (
ascending?,occurrences), never with the implementation's fns. A law that checks the code with the code is circular: if both are wrong in the same way, it still passes.
What a spec should not be:
- A list of examples. A law with no quantifier is fine as an anchor, but on its own it is only a test.
- A restatement of the types.
annalready covers those. - A restatement of the code, or a law that is true of anything. writ rejects these as vacuous.
writ enforces the last two points in the check:
- Vacuous laws fail. A law that calls no function of the target, or
that
writ.normproves without looking at the implementation, holds for any code, so it says nothing about this code. Examples are(= (isort xs) (isort xs))and(= (+ n 0) n). - Gaps fail. Once every law holds, writ swaps each signed public function for well-typed stand-ins: a constant, an argument passed through, and the real result perturbed (reversed, missing its first element, one more, or another value of the return type). If some stand-in still satisfies every law, the spec does not pin that function down, and the report says which stand-in got through:
the spec does not pin down `isort`: every law still holds when it always returns ()
or when it returns the real result without its first element
State what `isort` must do, so that a law rejects this.
That report is what a spec with only the sorted law produces. Adding
permutation closes both gaps.
One more stand-in catches a spec made only of anchors. When every law
calls a function with the same argument fixed to literals, the laws say
what it does at those values and nothing else. The stand-in agrees with
the real function there and returns something different everywhere else.
For a classifier whose laws only mention the sentinels -127, -2, -1
and 0, the report reads:
the spec does not pin down `classify-read`: every law still holds when it returns a different value whenever `n` is not one of -127, -2, -1, 0
State what `classify-read` must do, so that a law rejects this.
The stand-ins are a fixed family, so a spec that rejects them all can still be too weak. Passing this check is necessary for a good spec, not sufficient.
A spec namespace requires writ.spec and uses these forms.
(spec target.ns)names the namespace it constrains. It comes first.(ann f [A B -> R])gives fnfits parameter and return types. Every public fn needs one: a public fn with noannfails the check, since nothing would check it. Sign it if the plan has it, or make it private withdefn-if it is a helper. A private helper that recurses over a collection needs ananntoo, because writ has to know the collection is finite to accept the recursion.(refine Name [x Base] pred)is a type: the values ofBasewherepredholds. See Refinements.(graph name {...})is the problem's states and the steps between them. See The state graph.(data Name Ctor (Ctor2 FieldType ...) ...)declares a datatype the target's values use.(data Box [a] (Wrap a))takes type parameters.(law name proposition)states a law.(calls f [g ...])states exactly which fnsfcalls, and(calls f {:through [g] :not [h]})what it reaches. See The call graph.(flow f [param ...] [link link ...] ...)states the path data takes throughf. See Flows.(machine name {...})states that a fn steps a state machine by a transition table. See Machines.
A spec may define its own helper fns, like ascending? above. They run
only when laws run. A helper may not share a name with a public fn of the
target: a law's free name is read as the target's fn first, so the helper
would silently be replaced by the code it is meant to judge. writ rejects
the spec instead, and says which name to rename.
Built in: Nat Int Bool String Char Keyword Symbol Float Double Unit Any,
(List T), (Vec T), (Set T), (Map K V), (Tuple T ...), and
function types (-> A B R). Declared types come from data.
(List T) means any seq: a list, a vector, a lazy seq or nil. Generated
inputs mix all four, so code that only works on one of them fails. conj,
for example, prepends to a list and appends to a vector.
A generated Int stays between -max-size and max-size, so -50 to 50
by default, and a Nat between 0 and max-size. A law quantified over
Int therefore never reaches a value like -127. When specific values
matter, such as a return code's sentinels, anchor each one with a law that
names it: (law eof-sentinel (forall [e Bool] (= :eof (classify-read -127 e)))).
A data value is a vector headed by its constructor keyword: [:Leaf],
[:Node l v r]. The code builds one as a vector literal and takes it apart
with case on its tag:
(defn size [t]
(case (first t)
:Leaf 0
:Node (let [[_ l _ r] t] (+ 1 (size l) (size r)))))writ checks this statically:
- The
casenames only constructors of the type, and covers every one unless it has a default. That holds for a value destructured from a typedTupletoo: with a game typed(Tuple Phase Ball),(let [[phase ball] game] (case (first phase) ...))must cover everyPhase. - A clause destructures only the fields of its own constructor.
- A literal
[:Node ...]has exactly the fieldsNodedeclares, and each field whose type is known fits. A type parameter takes its type from the first field that is exactly that parameter, and must agree after that. - The value is read only through that
case.first,secondornthanywhere else is rejected.
A proposition is built from:
(= a b)(and P ...)(=> P Q), where cases in whichPdoes not hold are skipped(forall [x T, y U] P)(exists [x T] P)- any other expression, which holds when it is truthy
Inside a law, a free name refers first to the target's public fns, then to
the spec's own helpers, then to clojure.core.
Laws say what the code computes. They can't say how the code is put
together, and an implementation can compute the right thing through the
wrong structure: it inlines a helper instead of calling it, so the two
drift apart the next time the helper changes, or it skips a layer and
checks validity itself instead of going through the fn that owns the
rule. Every law still holds. calls states the structure:
(ns my.pipeline-spec
(:require [clojure.string :as str]
[writ.spec :refer [spec ann law calls]]))
(spec my.pipeline)
(calls normalize [str/lower-case str/trim])
(calls respond [valid?])
(calls handle [normalize respond]) ; not valid?: respond owns that(calls f [g ...]) means f's direct calls are exactly that set, no
more and no fewer. A plan often knows the layers but not every helper, so
the map form states reach instead:
(calls handle {:through [normalize respond] :not [str/upper-case]}):through names fns handle must reach, directly or through any chain
of the namespace's own fns; :not names fns it must never reach. A
reach that breaks the rule is reported with its path:
the call graph of `handle` is not the one the spec gives
`handle` reaches `clojure.string/upper-case`, which the spec says it never does: handle -> normalize -> clojure.string/upper-case
The call graph is read from f's source:
- A simple name is one of the target's own fns. A qualified name is a fn
of another namespace, resolved through the spec's aliases, so
str/trimisclojure.string/trim. - A fn counts when it is called or referred to as a value, as in
(map normalize xs). clojure.core, host members andfcalling itself are left out. Recursion is the termination rule's business.- A local binding that shadows a fn is not a call to it. The body is lowered and its locals renamed apart before the graph is read.
A calls failure is reported beside the laws, and it fails the check:
the call graph of `handle` is not the one the spec gives
`handle` does not call `respond`, which the spec says it calls
`handle` calls `valid?`, which the spec does not list
the spec says `handle` calls exactly normalize, respond. Call through the layers the spec names instead of around them.
A passing report lists each fn's call set.
calls also guards the line between pure code and effects. The static
check lets calls into other namespaces through unchecked, so a pure core
that writes to a storage namespace passes it, and passes every law that
doesn't look at the store. Its call set names the stray call:
`shorten` calls `shortener.store/remember!`, which the spec does not list. examples/ has this case, run
against a real server.
f may be a fn of another namespace, named through the spec's aliases.
That is how a spec reaches the effect shell, which writ doesn't check but
which is where the core gets called: (calls server/app {:through [core/handle]}) fails if the shell answers requests some other way. In
such a form, a simple name is a fn of that namespace.
(spec/call-graph 'my.ns) returns the graph of any namespace as
{f #{g ...}}. It reads the source without loading or checking it, so it
works on effect code too, and it's the quickest way to write a first
calls form. (spec/mermaid 'my.ns) renders the same graph as a mermaid
flowchart. Given a spec namespace, (spec/mermaid 'my.spec) draws the
target's graph with the spec laid over it. A call the spec doesn't list
is a dotted edge marked not in spec, and a listed call the code doesn't
make is an edge marked missing:
flowchart LR
handle["handle"]
normalize["normalize"]
respond["respond"]
valid_Q["valid?"]
handle --> normalize
handle -.->|not in spec| valid_Q
respond --> valid_Q
handle --x|missing| respond
writ also uses the call graph in its static rules, whether or not the
spec has calls forms:
- A definition refers only to definitions above it, so the graph has no cycles except self-recursion. A defn and the local fns it binds can't be mutually recursive either.
scanwalks the graph: a fn that calls one writ can't check is reported with "it usesf, which writ cannot check", so one effect deep in a call chain shows up at every caller above it.
A refinement is a type and a predicate:
(refine Paddle [y Int] (<= 0 y (- H PH)))
(refine Ball [b (Tuple Column Row Pace Spin)] true)
(refine Green [l (Tuple Keyword Nat)] (and (= :Green (first l)) (<= (second l) GREEN)))It goes wherever a type goes: in an ann, a forall, a graph's states,
another refinement. Its values are generated to satisfy it -- an integer
range is found once and sampled near its bounds and the spec's own
numbers, where code tends to break -- so a law never folds random inputs
into shape. The prover takes the predicate as a hypothesis, and the
static check sees the base type. refine also defines the predicate, as
Paddle?, for laws to use.
Every spec declares its graph, and it comes first. Its states are types; its edges are the fns that step between them:
(graph signal
{:start [:green [:Green 0]]
:states {:green Green, :yellow Yellow, :red Red}
:edges {:green {[tick] #{:green :yellow}}
:yellow {[tick] #{:yellow :red}}
:red {[tick] #{:red :green}}}
:before [[:yellow :red]]})An edge's key is the fn and the types of its other arguments. The state
is the first argument unless _ marks where it goes: [move-paddle Key]
is (move-paddle state key) for every Key, and [insert Nat _] is
(insert n state). An edge out of a tuple state may take a part of it
with first, second or last, which is how a fn that returns the next
state with something else leads back round: {:result {[first] #{:links}}}. writ checks the graph four ways:
-
Data flow. Each edge's fn must take its state's type and return its targets' type, by its
ann. -
Each edge is a law. An edge into refinements is an obligation named for the graph, the state and the fn,
signal:yellow:tick, run and proved like any law: the fn takes every value of its state into one of the states it names, and it never throws. The law is the target's predicate applied to the call,(Sorted? (isort xs))read as(ascending? (isort xs)), so it is proved the way the spec's own laws are. A step that breaks it is reported with the state it breaks on:law `signal:yellow:tick` fails for l = [:Yellow 5] a tick from yellow must land in yellow or red -
Each step is taken. An edge only bounds the code, and code that never leaves its state keeps every bound. So each target of an edge is also a law,
signal:green:tick->yellow: some value of the state, and some arguments, land there. A signal stuck on green fails it:law `signal:green:tick->yellow` fails the graph says a tick can take green to yellow, but no generated green does -
The graph's own rules.
:startnames a state, or[state value]with a value in it; every state must be reachable from it;:finalstates must be reachable from every state;:never [a b]says no path leads from a to b, and:before [a b]that every path from the start to b passes a.
What that adds up to: with every edge proved, :never and :before
hold for every run of the code, since a run only takes edges the graph
has. Reachability and :final are about the steps, each of which some
value of its state takes; they do not promise that a run from the start
gets there.
States must say what sets them apart. Two states of the same plain type,
like :unsorted (List Nat) and :sorted (List Nat), fail the check: a
value of one is a value of the other, so an edge between them checks the
type and nothing else, and the names say more than the graph does. Make
the state that means something a refinement, (refine Sorted [xs (List Nat)] (ascending? xs)), and the edge into it is the law. For the same
reason an edge may not list a plain state beside refined ones: every
result is in the plain one. Define a refinement with the same helpers the
laws use, so the prover sees one vocabulary.
A spec for plain functions has a graph too: its states are the data the problem moves through, and an edge into plain types is data flow only, checked against the signatures. pong's graph has four states, one per phase of a game, each a refinement of the game's tuple; its four edges are proved from the code, so no sequence of key presses ever takes a game out of them.
(spec/mermaid 'my.spec {:graph 'signal}) draws a graph as a mermaid
stateDiagram-v2, and (spec/plan 'my.spec) prints it with the rest of
the plan; see Other entry points.
Some code is a state machine: a screen flow, a protocol, an order's lifecycle. Its meaning is a transition table, and a table can be checked exhaustively instead of sampled:
(data Screen Logo Title Options Gameplay Paused Ending)
(data Event Timeout Confirm Configure Back Pause Finish Quit)
(ann next-screen [Screen Event -> Screen])
(machine screens
{:step next-screen
:start [:Logo]
:transitions {[:Logo] {[:Timeout] [:Title]}
[:Title] {[:Confirm] [:Gameplay], [:Configure] [:Options]}
[:Options] {[:Back] [:Title]}
[:Gameplay] {[:Pause] [:Paused], [:Finish] [:Ending]}
[:Paused] {[:Pause] [:Gameplay], [:Back] [:Gameplay], [:Quit] [:Title]}
[:Ending] {[:Confirm] [:Title]}}
:final [[:Title]]
:never [[[:Title] [:Logo]]]
:before [[[:Gameplay] [:Ending]] [[:Title] [:Gameplay]]]})writ checks two things. First, the code against the table: :step is run
on every state and every event, and each answer must be the table's, or
the same state where the table lists nothing. The states and events come
from the step fn's ann when their types are data whose constructors
have no fields; otherwise :states and :events list them. Here that is
42 pairs, all of them:
machine `screens`: `next-screen` does not follow its table
(next-screen [:Title] [:Pause]) is [:Paused], but the table says [:Title] (no transition listed: the state stays)
Second, the table against its own rules:
- every state is reachable from
:start :final: from every reachable state, some final state can be reached, so nothing is a trap:never [a b]: no path leads fromatob:before [a b]: every path from:starttobpasses througha
A broken rule is reported with the path that breaks it:
machine `screens`: the table breaks its own constraints
from [:Options] no final state can be reached
[:Title] must never lead to [:Logo], but it does: [:Title] -[:Confirm]-> [:Gameplay] -[:Pause]-> [:Paused] -[:Quit]-> [:Logo]
The table also takes part in the adequacy check as a law would, so a
machine alone pins its step fn down. (spec/mermaid 'my.spec {:machine 'screens}) draws the table as a mermaid stateDiagram-v2.
A machine and a graph differ where it matters. A machine's states and
events are values, :start is a value, and its table is exact: every
pair is run, and a pair the table doesn't list must keep the state. A
graph's states are types, :start is a state's name (or [state value]), and an edge says where a step may go and that it can: pairs
the graph doesn't list are unconstrained. Use a machine when the
meaning is a finite table, and a graph when the states are sets of
values.
calls says which fns are called. It doesn't say what they are given: a
fn can call every layer the spec names and still hand the second one the
raw input, throwing the first one's work away. flow states the path the
data takes:
(flow handle [req]
[req normalize respond :result]
[normalize :result])The vector after the fn names its parameters, by position, so the spec never depends on what the code calls them. Each chain after it is a path, and every link must reach the next:
areaches fnbwhen some call the fn makes tobis passed a value that comes froma: from the parametera, or from what a call to fnareturned, directly or through other calls.areaches:resultwhen what the fn returns comes froma. A branch's test counts, so a check that decides the answer reaches it.- A lambda passed to a fn, as in
(map (fn [x] (step x)) xs), is given that call's other arguments, and so is a fn passed by name. A loop's bindings carry what eachrecurpasses.
The check reads the fn's source. A flow that the code breaks names the link:
the flow of `handle` is not the one the spec gives
`respond` is never given anything that comes from `normalize`
what `handle` returns does not come from `normalize`
A flow names only parameters and fns; a name that is neither, or a
parameter count that doesn't match, fails the check. Like calls, a flow
may be about a fn of another namespace, such as an effect shell. A fn a
flow names is a step of the plan, like a graph's edge fns.
(writ.spec/check 'my.sort-spec) returns a report map:
{:ok true
:spec my.sort-spec
:target my.sort
:static {:ok true}
:laws [{:law sorted :status :proved :evidence :proof ...}
{:law smallest-first :status :tested :evidence :test :trials 100 ...} ...]
:proof {:require :tested :proved 6 :tested 1 :laws 7}
:gaps [] ; fns the laws don't pin down
:rejected [{:fn isort ...}] ; per fn: its laws and the stand-ins they rejected
:calls [{:fn handle :calls [normalize respond] :status :ok} ...]
:flows [{:fn handle :chains ["req -> normalize -> respond -> result"] :status :ok} ...]
:graphs [{:graph sorting :status :ok :states 2 :edges 2} ...]
:machines [{:machine screens :status :ok :states 6 :events 7} ...]
:unspecified [] ; public fns with no ann: each fails the check
:off-graph [] ; signed public fns no graph, machine or flow names
:message "writ.spec: my.sort-spec against my.sort: ok\n `insert`: ..."}It works in three stages, and each runs only if the one before passed.
-
Static. writ reads the target's source from the classpath, puts the
anntypes on itsdefns and checks it (see below). If this fails,:staticcarries the error and no law runs. Nor does one run when a helper of the spec shares a name with a public fn of the target;:ambiguousnames it. -
Laws. Each law gets a
:status::vacuous: it holds whatever the code does, so it fails; see What a spec should say.:evaluated: a law with no quantifiers, decided by running it once.:tested: aforall, run by test.check on generated inputs. A failure is shrunk to a small counterexample.:witnessed: anexists, found by test.check and shrunk to the simplest witness.:proved: a law that passed its tests and that the prover also derived from the code's source for every input; see Proofs.:proofsays how, for example "by induction on xs, splitting on (<= x xs-h)".
A tested law has been tested, not proved; the status keeps the two apart. A tested law the prover could not prove carries
:unprovedwith the reason. Each law's:evidenceis:proof(proved, evaluated or witnessed) or:test, and:proofin the report counts them. When the spec requires proof, a law that is only tested gets:status :unprovedand fails; see Requiring proof. While laws run, the target's signed fns are instrumented, so a value of the wrong type fails at the fn that produced it. -
Adequacy. When every law holds, each signed public fn is swapped for stand-ins, and any stand-in that satisfies every law is reported in
:gaps. See What a spec should say. A passing report lists, per fn, how many laws call it and how many stand-ins of each kind they rejected, so a fn that only a couple of constants were tried against stands out:writ.spec: my.sort-spec against my.sort: ok `insert`: 2 laws, 5 impostors rejected (2 constant, 1 pass-through, 2 perturbed) `isort`: 2 laws, 5 impostors rejected (2 constant, 1 pass-through, 2 perturbed)
The calls forms are checked once the static stage passes, beside the
laws, and each gets an entry in :calls with :status :ok or
:failed, plus :missing and :extra when it failed (:unreached and
:reached for the map form). Each flow gets an entry in :flows, with
:errors when it failed. A passing report lists the signed public fns
that no graph, machine or flow names, as not a step of any graph or machine: a public helper is fine, but a reader should see it. Each machine
gets an entry in :machines; a failed one carries :mismatches, one
{:state :event :expected :actual} per pair the code gets wrong, and
:errors for the table's own rules.
Options: :target checks a different implementation against the same spec,
:trials is the number of test.check runs per law (default 100), :seed
replays a run (default random, reported per law), and :max-size is the
largest generated size (default 50). :adequacy false skips the third
stage, for example while a spec is still being written, and
:prove false skips the prover. :require sets the evidence every law
needs, in place of the spec's own. :proof names the proof namespace
(false for none), and :fuel gives the prover more rewrites per
attempt.
Proofs are cached in .writ-cache/, one file per spec. A cached proof is
used only when the law, its hint and lemmas, and the source of the code,
the spec, the proof namespace and writ itself are all exactly as they
were when it was found and checked, so a check that changes nothing
proves nothing again. :cache false turns it off; :cache-dir puts it
elsewhere. Add .writ-cache/ to .gitignore.
A tested law has passed some trials; a proved one holds for every input. By default a spec accepts either, and the report says which each law got:
writ.spec: my.sort-spec against my.sort: ok
6 of 7 laws proved; tested, not proved: smallest-first
A spec can require proof. Then a law that is only tested fails:
(spec my.sort {:require :proved})law `smallest-first` is tested, not proved, and the spec requires proof
the prover: no proof found
...
A single law can ask for more or less than the spec. Letting a law off proof takes a reason, and every report shows it, so an unproved law can't go unnoticed:
(law sorted {:require :proved} ...) ; in a spec that allows tests
(law smallest-first
{:require :tested :because "the prover has no model of min over a list yet"}
(forall [xs (List Nat)] ...))A closed law that evaluates to true, and an exists law with a witness,
count as proved: running pure, terminating code on fixed inputs decides
them.
When a law holds but the prover can't find its proof, it needs a lemma or
a hint, and those don't belong in the spec: the spec is the contract, and
lemmas are how it is proved. They go in the proof namespace, found by
name (my.sort-proof for my.sort-spec) or given as check's :proof:
(ns my.sort-proof
(:require [writ.spec :refer [proof-of lemma hint]]))
(proof-of my.sort-spec)
(lemma insert-keeps-sorted
(forall [x Nat, xs (List Nat)]
(=> (my.sort-spec/ascending? xs) (my.sort-spec/ascending? (insert x xs)))))
(hint sorted {:induct xs :use [insert-keeps-sorted]})A lemma is a law about the code: it is tested, and it must be proved, or
the check fails. Once proved, the spec's laws may cite it. It is reported
apart, it counts toward no law of the spec, and the adequacy check never
judges a stand-in by it, so a lemma can't make a weak spec look strong.
A lemma may be about clojure.core alone, such as what a bound on a list
says about a filter of it.
A proof namespace may define helpers for its lemmas with defn, such as
an invariant the code keeps. The prover reads them as it reads the
target's, and a helper that refers to the target's fns reads those of
the target under check.
A lemma's hypothesis may name a variable its conclusion doesn't. The prover finds that variable's value in the facts of the goal at hand, the way ACL2 does:
(lemma none-below
(forall [x Nat, v Nat, xs (List Nat)]
(=> (and (every? #(> % v) xs) (<= x v)) (= (filter #(< % x) xs) ()))))rewrites (filter #(< % x) r) to () wherever the facts say every
element of r is above some v with x <= v.
A hint steers the search and nothing more: :induct the variable to try
induction on first, :vary the other variables the induction hypothesis
holds at every value of (an accumulator a fold passes on), :use the
only lemmas and laws a proof may cite, :strategy one of :symbolic,
:induction or :rewriting, and :fuel the rewrites one attempt may
make. A proof a hint leads to is checked like any other.
writ.spec-demo.tree-proof in the tests proves the tree's
holds-a-sorted-set this way: a bst? invariant, lemmas that insert
keeps it and that listing the tree after an insert is inserting into the
list, and a fold lemma with the tree varying.
After a forall law passes its tests, writ tries to prove it from the
code. It translates the law and the target's defns into terms and
rewrites them to normal form. When that isn't enough, it tries structural
induction on each quantified variable, with the law at every smaller value
as a hypothesis:
- a list is nil, empty, or a head and a tail
- a
Natis 0 or p + 1 - a datatype has one case per constructor
Within a case, the prover:
- splits an open integer comparison into its two outcomes, and substitutes an equality that holds
- splits an unknown tail into empty, and a head and a tail. That is how
insert-keeps-sortedsees the second element. - decides integer conditions from all the facts at once (Fourier-Motzkin
elimination, tightened for integers), so
a <= b,b <= candc < aare seen to contradict each other
When a case still isn't closed, the prover tries two things:
- Generalising. It uses an equality hypothesis the other way round,
replaces the recursive call that brings in with a fresh variable, and
proves that more general goal by an induction of its own.
permutationis proved this way, with(isort xs-t)generalised. - Sorting. On a list of integers,
(sort xs)and(sort (distinct xs))are a fold that inserts each element into the sorted list so far: the elements below it, it, the elements above it (at or above, when duplicates stay).writ.prove.rewrite/model-checkruns the model againstsortitself. - An accumulator. A fold that grows an accumulator by addition from
0, as a
loopor areduce, is first proved to give acc plus the fold from 0, from any integer acc, by induction with acc left free in the hypothesis. The law is then proved citing that.
A law proved earlier is a lemma for the laws after it: an equality rewrites its left side to its right, anything else rewrites to true, when its hypotheses hold.
A lemma holds only at its own types, and the prover's logic is untyped,
as ACL2's is, so a type is a hypothesis like any other. Each term a
lemma's variable takes must be shown to be of the variable's type: a
variable of that type is; an integer term is an Int, and a Nat when it
can't be negative; anything else must satisfy the type's recognizer,
which the prover builds from the spec's data and takes values apart
with the way the type's cases do. A signature is only a claim, so before
the laws, the prover proves each signed fn's contract from its code, as
ACL2s's defunc does: given arguments of its parameter types, it returns
a value of its return type. Then (insert x t) is known to be a Tree.
A set, a map or a fn has no recognizer, and takes only a variable of its
own type. Contracts for Nat and Int returns aren't proved yet. The passes repeat until nothing new is proved, so a
law may cite one that comes later in the spec. A law that is only tested
is never cited. The report names what each proof used:
writ.spec: my.sort-spec against my.sort: ok
law `sorted` proved by induction on xs, citing insert-keeps-sorted
law `permutation` proved by induction on xs, generalising (isort xs-t)
law `insert-keeps-sorted` proved by induction on xs, splitting on (<= x xs-h), with cases on xs-t
law `insert-adds` proved by induction on xs
The model follows Typed Clojure's, so the prover keeps the distinctions Clojure makes:
niland()are different values, andseqis the bridge between them.restis never nil, andnextcan be.- Lists, vectors, cons cells and lazy seqs are one kind of value. The ops
that tell them apart (
conj,peek,vector?, ...) are outside the model, so a law that needs them stays tested. - A lazy seq is truthy before it is realised, so deciding one never runs its elements.
=is Clojure's: sequentials compare element by element,1never equals1.0, and a term equals itself only when no float can be inside, becauseNaNis not=to itself.- Integers are exact (jolt promotes on overflow).
+is associative and commutative only on integers. Floats get no algebra. A term counts as an integer only when its form or a proved fact says so, never because a signature says so.
A proof holds for every input on which the law's terms return a value. That is Typed Clojure's notion of soundness, well-typed code returns or throws. Since writ also runs every law, an input where a term throws still fails the check. One step takes a signature at its word: a generalised call ranges over its fn's signed return type. The laws' runs check every signature on every trial.
Several things guard the prover itself:
- The checker. Every proof is replayed before it is reported, by a checker that searches for nothing. It rebuilds each step and confirms it: an induction's cases are exactly its type's cases, each split proves both sides, a generalisation uses a hypothesis the case really has, and a lemma is cited only if it was proved. A proof it rejects is not reported. What must be trusted is the rewrite rules, the induction schemes and the checker, not the search.
- Each rewrite rule is checked against the runtime by test.check.
- Random closed terms are normalised and run, to check that the normaliser agrees with jolt.
- A proof must unfold one of the target's own definitions. One that never looks at the code would say nothing about it.
- A law that is proved and then refuted by a test value is reported as a writ bug.
The prover covers:
seq,first,rest,next,second,empty?,count,cons,list,vector,concat,filter,map,reduceandnth=, integer arithmetic and comparisons,integer?,if,case,letand destructuring- core fns passed as values, with
applyof<=,<,>=,>and+, so a predicate like(apply <= xs)translates - fn literals,
loop/recur, and the target's and the spec's owndefns
Anything else leaves the law tested, with :unproved saying why, for
example "outside the prover: frequencies".
Rewriting splits a goal at every if, and a step fn with a dozen
conditions makes thousands of cases. So writ also runs the code once, on
symbolic values, the way Rosette
does: both branches of an if run, and their values merge under its
test. Integers merge into one value defined once, vectors of one length
merge element by element, and a data value whose constructor depends on
the branch is kept as a union of guarded values. Data values are split
into their constructors first, so each case is a small formula. What
comes out is one formula: the law holds, and, for a graph's edges, the
code never throws.
That formula goes to writ.solve, a solver written for writ in plain
Clojure: linear integer arithmetic (with mod and quot by a constant),
uninterpreted functions, and sets of unknown size as predicates. Two sets
are equal when an element the solver may choose is in both or neither, so
a law about every world of the Game of Life is one query:
the-plane-has-no-favoured-place is proved for every world, however
large, not sampled. The solver's search is not trusted. An unsatisfiable
formula comes with a certificate -- case splits down to Farkas
combinations, whose sums a few lines of arithmetic check -- and
writ.solve.cert verifies it, as the proof checker does for every step.
When the solver finds the formula invalid, its model is a counterexample. writ turns it back into Clojure values and runs the law on them; if the law fails there, that is the report, even when no test found it:
law `the-left-paddle-stops-the-ball` fails for
b = [3 3 -2 0]
ly = 0
ry = 4
(held-by-left? ly (advance b ly ry)) => false
(advance b ly ry) => [1 3 -2 0]
(found by the solver, when no test did, and confirmed by running the code)
Symbolic evaluation covers the non-recursive code: arithmetic, if,
case, let, destructuring, vectors of known length, data values, sets
built from literals and sets of unknown size filtered, mapped by a
translation, or tested for membership, and calls of pure core fns on
literal data. Recursion is left to rewriting and induction.
Before writing a spec, (spec/scan 'my.ns) says which of a namespace's
top-level forms writ could check. It reads the source without loading it,
checks each form in order against the ones above it that passed, and gives
writ's own reason for each one that fails. A fn that fails only because it
recurses over a collection with no type is listed apart, since an ann
fixes that. A fn that calls a rejected one says which. For a namespace
that mixes pure code with IO:
writ.spec/scan writ.spec-demo.scan-mixed: 1 of 6 forms can be checked, 1 more once signed
can be checked:
classify
can be checked once an `ann` types its collection:
total: recursive call to `total` does not descend: argument 1 shrinks `xs` without a guard; ...
cannot be checked:
shout (private): `.toUpperCase` is host interop or effect code; writ checks pure data-and-functions code only
log!: `println` in `log!` is effect code (...); writ checks pure data-and-functions code only
loud-classify: it uses `shout`, which writ cannot check
Conn (defrecord): `defrecord` is not supported: in the code a spec covers, writ checks def and defn forms only (...)
The report map has the same in :forms, one entry per form with
:status :ok, :needs-ann or :no and a :why.
check! does the same as check but throws with the message when anything fails.
(spec/instrument 'my.sort-spec) wraps the target's signed fns with runtime
argument and return checks for use at the REPL, and unstrument removes
them. (spec/sample '(List Nat) {} 5) shows what a type generates.
(spec/call-graph 'my.ns) and (spec/mermaid 'my.ns) read a namespace's
call graph; see The call graph. (spec/flow-facts 'my.ns 'f) shows what flow reads: each call f makes, with where each
argument's value comes from, and where its result comes from.
(spec/plan 'my.spec) prints the spec as a plan for a person to read and
confirm, from the spec alone, so it works before the code exists: each
graph's states, with a refinement's predicate spelled out, and its steps
and rules; each signed fn with its signature, the laws that name it, its
flows and its call set; and the wiring the spec gives fns outside the
target, such as a shell's.
plan: my.pipeline-spec for my.pipeline
graph `request`
states
:raw String
:clean Clean, a String where (= s (cleaned s))
:response (Tuple Keyword String)
steps
:raw -[normalize]-> :clean
:clean -[respond]-> :response
fns
handle [String -> (Tuple Keyword String)]
laws: handle-answers-with-the-cleaned-input, ok-exactly-when-short
flow: s -> normalize -> respond -> result
calls exactly: normalize, respond
(spec/mermaid 'my.spec {:graph 'g}) draws graph g as a mermaid
stateDiagram-v2.
These rules apply to the plain implementation.
-
Pure code. No host interop (
throw,new,.foo,reify, static members such asSystem/getenvorMath/abs), no effects (I/O, atoms and refs, futures,eval, var mutation, randomness), no reflection. Calls into other namespaces pass through unchecked. writ reads source without loading it, so a qualified name whose qualifier ends in a capitalised segment is taken to be a class. -
Top-level forms. Only
ns,comment,defanddefn. Adefmulti,defrecord,defmacroor bare expression is rejected rather than skipped. Each fn has a single arity. -
Order. A definition refers only to definitions above it, so there is no mutual recursion. There are no forward declarations.
-
Termination. Every recursive call,
recurincluded, must pass a structurally smaller part of one parameter, under a test that proves the shrink:restandnextneedseqorempty?decneedspos?, orzero?on aNat. A chain of them needs a guard at each depth:(dec (dec n))under(zero? n)and(zero? (dec n))both false(- n k)for a literalkiskdecs, so it needsnto be at leastk;(< n k)false or(>= n k)true proves it, and so does any comparison ofnwith a literal that implies it- element reads such as
first,nthand destructured fields need the value to be non-nil. A truthiness test works, and so does acaseon(first t)
A test may sit inside
and:(if (and (pos? fuel) (pos? n)) ...)proves both in its then branch. Anorproves each of its tests false in its else branch. A recursion with no structural measure, such as one on(quot n 62), takes a fuel parameter that counts down, asencode-idin examples/shortener does.Parameters before the shrinking one pass through unchanged, so an accumulator goes after the collection it walks, in the parameters or the
loopbindings.restonly shrinks a collection writ knows is finite, which is one reason to sign the fn. The loops thatdoseqand similar macros expand to are checked the same way, and the error names the collection they walk. -
Arity and calls. Every call matches its fn's arity, including
clojure.corefns, and nothing that is not a fn is called. -
Types. Arguments fit the signed parameter types, and the body fits the signed return type. Unsigned code is inferred where writ can (literals, arithmetic, typed calls); an unknown type never fails on its own. A
Natwidens toInt. -
Data is taken apart with
caseon its tag and built with the right fields, as described under Data.
writ can also check code that carries its annotations inline, through the
macros in writ.defn. This surface enforces Bend's full discipline,
including quantities, and states laws and proofs in the code.
(require '[writ.defn :as w])
(w/data MaybeInt Nothing (Just Int))
(w/defn ^{:writ/descend true} add [^:many a :- Nat, b :- Nat] :- Nat
(if (zero? a) b (add (dec a) b)))
(w/defn from-maybe [m :- MaybeInt]
(w/match m :- MaybeInt
(Nothing 0)
((Just x) x)))On top of the rules above, this surface adds:
- Quantities. A binder with no mark is affine and used at most once.
^:manymakes it reusable, which requires a Data type (a function never is).^:zeromakes it erased, so it must not be used. - Descent marker. Recursion needs
^{:writ/descend true}. - Type variables. They are declared in the attr-map:
{:writ/forall [a, b :- Data]}. - Data.
w/datavalues are:Nothingor[:Just x]and are taken apart only withw/match, which checks exhaustiveness. It also matchesNat,Booland(List T). - Laws and proofs.
(w/law name prop)must be discharged by a(w/proof name law term). The terms arerefl,pair,(fn [x] ..)and(witness t pf), checked bywrit.norm. It has no induction and does not unfold your own fns, so it can only prove identities; it cannot check behaviour. That is the gapwrit.specfills.
Every w/ macro expands to the plain form it annotates, so annotated code
runs as if writ were absent. writ.core/check-book checks a quoted vector
of forms, and writ.book/check-files checks source files as one book.
skills/writ/SKILL.md lists every rule and error message.
writ.spec: spec namespaces, law checking, the call graph, instrumentwrit.prove: the proof search;writ.prove.term,writ.prove.rewriteandwrit.prove.translatehold the terms, the rewrite rules and the translation from Clojure;writ.prove.schemethe steps a proof is made of, andwrit.prove.checkthe checker that replays every proof;writ.prove.symbolicruns code on symbolic values, andwrit.prove.smthands open goals to the solverwrit.solve: the certifying solver for linear integer arithmetic and uninterpreted functions;writ.solve.preturns formulas into clauses,writ.solve.searchandwrit.solve.simplexsearch, andwrit.solve.certchecks certificates without searchingwrit.book: runs every rule over a namespace's formswrit.check: quantities, termination, ordering, effects, aritywrit.types: type checking and tagged datawrit.kind: type well-formedness and the Data/Type latticewrit.lower: macroexpanded forms to a small core ASTwrit.uses: path-sensitive occurrence analysiswrit.match:w/matchdisciplinewrit.norm: normalisation and convertibilitywrit.law: propositions and the proof gatewrit.data: data declarationswrit.quant,writ.ann: the quantity lattice and its metadatawrit.defn: the annotated surface macroswrit.core: entry points for the annotated surface
writ.spec depends on org.clojure/test.check, and writ.prove on
org.clojure/core.logic, whose unification matches the rewrite rules. The
rest of writ has no dependencies.
jolt -M:test # or ./bin/test
cd examples && jolt -M:test # the example programs and their specs
test/writ/spec_demo/ holds the worked example: an insertion sort and a
binary search tree, each with a spec, one correct implementation and
several broken ones. Every broken one is rejected with a report that points
at the fix.