Skip to content

Prove specs over every input: state graphs, symbolic evaluation, a certifying solver - #8

Merged
yogthos merged 9 commits into
mainfrom
proofs-over-every-input
Sep 24, 2026
Merged

yogthos merged 9 commits into
mainfrom
proofs-over-every-input

Conversation

@yogthos

@yogthos yogthos commented Sep 24, 2026

Copy link
Copy Markdown
Member

Writ used to test most laws and prove few: the life, pong and fetch specs proved none. This makes proof the default path.

  • Every spec declares a state graph. States are types, usually refinements (refine); an edge into refinements is an obligation that the step lands in the named states and never throws. The graph's own rules (:never, :before, :final, reachability) then hold for every run of the code.
  • Reports say which laws are proved and which only tested; (spec ns {:require :proved}) fails on anything unproved, and letting a law off needs a stated reason.
  • writ.solve: a pure Clojure solver for linear integer arithmetic and uninterpreted functions, with certificates checked by a small independent checker. The search is never trusted.
  • The prover runs non-recursive code on symbolic values (merged branches, unions for data values, sets of unknown size as predicates) and hands the formula to the solver; open linear goals, bounded integers and data constructors are handled too. A solver counter-model becomes a counterexample, confirmed by running the code.
  • A proof namespace (proof-of, lemma, hint) holds what the prover needs without changing the spec. Lemmas must be proved and never count as laws.
  • Proof results are cached against the exact sources they came from, per spec and target.

In the examples, pong, life and fetch now prove every universal law and graph edge. life's the-plane-has-no-favoured-place holds for every world. pong's tunnel bug is found by the solver when no test finds it.

Fixed along the way: adequacy sampled all variables of one type with the same seed, so laws relating two of them couldn't reject stand-ins; a graph state of a compound type crashed the check; and hypotheses weren't taken apart.

Not done: the ACL2-style induction heuristics (R4) beyond testing generalised goals, and the BST holds-a-sorted-set proof (writ-tpr), which still needs a model of sort and distinct. life.fast uses frequencies, which the prover can't handle, so it is only tested.

The examples suite is slow on a cold cache: pong alone takes several minutes to prove.

Yogthos added 9 commits September 23, 2026 14:04
writ.solve.pre brings a formula over integers to clauses of integer
inequalities and booleans: mod, quot, abs, max, min and ite name fresh
variables with their defining cases, uninterpreted fns and predicates
are Ackermann-expanded, and the rest goes through Tseitin. writ.solve.cert
rebuilds those clauses and checks a proof tree over them: splits, clauses
false on a branch, Chvatal-Gomory cuts and Farkas sums. It searches for
nothing.
A from-scratch Dutertre-de Moura simplex over ratios explains an
infeasible bound set as a Farkas sum. DPLL with unit propagation calls it
at each node, closes integrality with Gomory cuts and then
branch-and-bound, and records the search tree as the proof, dropping any
split whose subtree never used its literal.
The API over the search and the checker, with tests: known sat and unsat
cases, mod and quot against clojure.core, integer-only infeasibility,
min monotonicity, EUF, a Game of Life shift, budgets, tampered
certificates, and random formulas against brute force.
# Conflicts:
#	test/writ/test_runner.clj
A search node now starts the simplex from its parent's tableau and only
repairs the bounds it adds, and unit propagation looks only at the clauses
holding the negation of a literal just made true. The slowest query from
the pong spec went from 38s to 2.5s. Certificates are unchanged, so the
checker is too.
…space

Every spec now declares its state graph. An edge into refinements is an
obligation that the step lands in the states it names and never throws;
a spec with no graph fails the check.

The prover runs non-recursive code on symbolic values, Rosette style:
branches merge, data values whose constructor depends on a branch are
unions, sets of unknown size are predicates and equal by extensionality.
The formula goes to writ.solve, and the checker verifies its certificate.
Data variables are split by constructor first, bounded integers are
enumerated, and open linear goals go to the solver too. A counter-model
from the solver becomes a counterexample, run on the code before it is
reported, so bugs no test samples are still found.

A proof namespace (proof-of, lemma, hint) holds what the prover needs and
the spec should not say; lemmas must be proved and never count as laws.
Proof results are cached against the exact sources they came from.

Also fixed: a graph state of a compound type crashed the check, an `and`
hypothesis was not taken apart, and integer refinements were sampled
uniformly, missing the edges of their range.
pong, life and fetch now prove every universal law and every graph edge.
life's the-plane-has-no-favoured-place holds for every world, not packed
6x6 samples. pong's tunnel bug is found by the solver where no test
finds it. life.fast counts with frequencies, outside the prover, so it is
checked with :require :tested.
A won pong game needs its tag and an exact score together, and 200 tries
sometimes found none, failing a law at random. Generation now tries 5000
times, and says which refinement starved if it still can't.
@yogthos
yogthos merged commit 1f32177 into main Sep 24, 2026
1 check passed
@yogthos
yogthos deleted the proofs-over-every-input branch September 24, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant