How it works
No compiler fork, no macros. The verifier reads the CoreFn JSON the PureScript compiler already emits, pairs it with your spec file, and turns each function into a handful of small logical obligations that Z3 either proves or breaks.
Two inputs converge. On the left, spago build (with a no-op
backend) drops corefn.json for each module. On the right,
your .lps file is parsed to a spec. They meet at
Vc, the verification-condition generator, which walks the
code in checking mode and emits obligations. Each obligation
becomes SMT-LIB2 text piped to the z3 binary.
Every arrow is a plain ADT-to-ADT function; the only effects are reading
files and spawning z3. The SMT boundary is text, so
every obligation can be dumped with --smt2-dir and replayed by
hand — the same inspectability principle as differential conformance in the
backends family.
CoreFn is the compiler's desugared core: if/then/else is
already a Case, guards are alternatives, and every node
carries a source span. It is also backend-agnostic — the same core the
Erlang and other backends consume — so a proof about the core holds for
any backend's output.
One wrinkle shapes the design. The compiler floats type-class
methods: n > 0 is not an application of
greaterThan at the use site. Instead a synthetic module-local
declaration appears —
Demo.greaterThan = Data.Ord.greaterThan ordInt — and the use
site references that local. Resolve scans for that shape
and builds a table mapping each local name to a primitive SMT operation
(>, +, -, &&,
…) for Ord, Ring, Semiring,
EuclideanRing, Eq, and
HeytingAlgebra on Int / Boolean.
Types come from docs.json, not externs —
externs are CBOR now, but docs.json carries full type ASTs as
JSON. Specs are cross-checked against it: an arity or base-type
disagreement fails the function before any solving, so a spec that
has drifted from its code is caught as a mismatch, not a false verdict.
Take a spec f :: t1 -> tr and its definition
f x = body. The verifier builds a context Γ — a bag
of facts it may assume — and asks whether the result refinement must hold.
abs :: Int -> Nat, the argument
is unrefined, so Γ starts empty.Case on a Boolean adds the scrutinee to Γ on the
true branch and its negation on the fallback. Guards do the
same, each later guard also assuming the earlier ones failed. A
non-recursive let binds an equality into Γ.check-sat.
unsat means the goal can't be broken —
SAFE. sat hands back a model — a
concrete counterexample — UNSAFE.bad failsbad n = n - 1 against Nat = { v : Int | v >= 0 }:
Γ = { } -- n is unrefined
body embeds = n - 1 -- Resolve maps Demo.sub to (-)
obligation = { } ⊢ (n - 1) >= 0
SMT = (declare-const n Int)
(assert (not (>= (- n 1) 0)))
(check-sat)
result = sat, model n = 0 → UNSAFE at Demo.purs:9:9
And abs, same result type, produces two obligations — one per
branch — n > 0 ⊢ n >= 0 and
¬(n > 0) ⊢ (0 - n) >= 0 — both unsat when negated, so
SAFE.
A call to a spec'd function checks that function's precondition at the call site (an obligation) and assumes its postcondition via a fresh result variable. Recursion assumes the function's own spec — standard inductive reasoning. Call facts are scoped per branch, so a postcondition justified under one path can't leak into a sibling.
An application of an unknown function, or a let the logic
can't embed, becomes a fresh unconstrained variable. The tool may reject
good code (imprecision), but it will never bless bad code inside the
trusted fragment. The logic stays in QF_LIA —
quantifier-free linear integer arithmetic — so Z3 is a decision
procedure, never a heuristic; “unknown” is not a possible answer.
The build turned up two failures that are really facts about the boundary being crossed — worth keeping.
Adding juxtaposition application to the spec parser (so
len xs reads as “apply len to xs”)
made the application parser greedy: it happily swallowed the following
type keyword as a third argument to a measure. The fix was to
make the reserved words actually reserved — the application parser has to
stop at a keyword, not consume it. A satisfying bug: the grammar quietly
told you where one declaration ends and the next begins.
Array is spoken for
The natural SMT sort name for PureScript arrays is Array — but
SMT-LIB reserves Array for its own theory of arrays, and Z3
rejected the declaration outright. So at the SMT boundary the sort is
renamed PSArray; element types are erased and only
length is visible to the logic. The lesson generalises: the
text boundary to the solver is a foreign namespace, and crossing it means
respecting its reserved words.