This tutorial teaches you how to write verified Clojure code with Ansatz. No theorem proving or Lean 4 experience needed — just Clojure.
By the end you'll understand:
- How
a/defndiffers fromdefn(and why the difference matters) - What the kernel actually checks
- How to prove properties about your code
- How
grindautomates most proofs
(require '[ansatz.core :as a])
;; For this tutorial, init-medium is enough (ships with the repo, no Lean needed):
(a/init! "test-data/init-medium.ndjson")
;; Or with full Mathlib (648k theorems):
;; (a/init! "/var/tmp/ansatz-mathlib" "mathlib");; Regular Clojure:
(defn double [n] (+ n n))
;; Verified Clojure:
(a/defn double [n Nat] Nat (+ n n))The difference: a/defn takes type annotations. [n Nat] means n is a natural number. The return type Nat comes after the params. The kernel checks that (+ n n) actually produces a Nat when given a Nat.
The result is a normal Clojure function:
(double 21) ;; => 42What happens under the hood:
Your code (+ n n) compiles to a CIC expression:
λ (n : Nat) => HAdd.hAdd Nat Nat Nat (instHAdd Nat instAddNat) n n
That looks complex, but it's just n + n with all the implicit plumbing made explicit:
HAdd.hAddis the generic+operatorNat Nat Natare the operand and result typesinstHAdd Nat instAddNatis the typeclass instance proving Nat supports additionn nare your two operands
You wrote (+ n n). Ansatz figured out the rest.
Lists are the bread and butter of Clojure. In Ansatz, List Nat is a verified list of natural numbers with two constructors: nil (empty) and cons (head + tail).
;; Length of a list
(a/defn len [l (List Nat)] Nat
(match l (List Nat) Nat
(nil 0)
(cons [hd tl] (+ 1 ih_tail))))The match expression does structural recursion on l:
- If
lisnil→ return 0 - If
liscons hd tl→ return1 + ih_tail
What's ih_tail? It's the induction hypothesis — the result of calling len recursively on tl. The kernel provides it automatically because tl is a structural subterm of l. You don't write the recursive call explicitly — the kernel guarantees termination.
This compiles to a List.rec application (the recursor — the only way to eliminate a List in CIC). The recursor is what makes termination checking possible.
(len '(1 2 3)) ;; => 3More examples:
;; Append two lists
(a/defn app [xs (List Nat) ys (List Nat)] (List Nat)
(match xs (List Nat) (List Nat)
(nil ys)
(cons [hd tl] (cons hd ih_tail))))
(app '(1 2) '(3 4)) ;; => (1 2 3 4)
;; Map a function over a list
(a/defn lmap [f (arrow Nat Nat) l (List Nat)] (List Nat)
(match l (List Nat) (List Nat)
(nil nil)
(cons [hd tl] (cons (f hd) ih_tail))))
(lmap inc '(1 2 3)) ;; => (2 3 4)When you define a function with match, Ansatz automatically generates equation theorems — one per branch:
len.eq_1 : len [] = 0
len.eq_2 : len (x::xs) = 1 + len xs
These say: "calling len on nil gives 0" and "calling len on cons gives 1 + recursive result." They're the key to proving things about len — tactics like simp and grind use them to unfold the function one step at a time.
Why one step? Because if you unfold everything at once, + would expand into kernel internals (Nat.brecOn, PProd...) and become unreadable. The equation theorems keep things clean.
A theorem states a property and proves it:
(a/theorem len-nil []
(= Nat (len nil) 0)
(rfl))Reading this:
- Name:
len-nil - Parameters: none (
[]) - Statement:
len nil = 0(equality of Nat values) - Proof:
(rfl)— "reflexivity"
(rfl) works when both sides of the = reduce to the same value. The kernel computes len nil:
- Unfold
len→ it's aList.recapplication List.reconnil→ selects the nil branch →0- Both sides are
0✓
Here's another:
(a/theorem app-nil [ys :- (List Nat)]
(= (List Nat) (app nil ys) ys)
(rfl))app nil ys reduces to ys by definition (the nil branch returns ys). Both sides are equal → rfl.
But this fails:
;; This does NOT work with rfl:
(a/theorem app-nil-right [xs :- (List Nat)]
(= (List Nat) (app xs nil) xs)
(rfl)) ;; ERROR!Why? Because xs is a variable. The kernel can't reduce app xs nil — it doesn't know if xs is nil or cons. We need induction.
Induction splits a goal into cases based on the structure of a value:
(a/theorem app-nil-right [xs :- (List Nat)]
(= (List Nat) (app xs nil) xs)
(induction xs)
(all_goals (try (simp_all "app")))
(all_goals (try (grind "app"))))(induction xs) creates two goals:
Goal 1 (nil): app nil nil = nil
- Both sides reduce to
nil→ closed by simp
Goal 2 (cons): app (hd::tl) nil = hd::tl
- With IH:
app tl nil = tl simp "app"unfolds one step:app (hd::tl) nil → cons hd (app tl nil)- Goal becomes:
cons hd (app tl nil) = cons hd tl - The IH says
app tl nil = tl - By congruence:
cons hd X = cons hd YwhenX = Y→ done!
(all_goals ...) applies a tactic to every open goal. (try ...) means "if this tactic fails, skip it." This pattern — (induction x) (all_goals (grind "defn")) — handles most list properties.
grind is the automated reasoning tactic. It combines several strategies:
;; Map preserves length (grind handles everything after induction)
(a/theorem map-len [f :- (arrow Nat Nat), l :- (List Nat)]
(= Nat (len (lmap f l)) (len l))
(induction l) (all_goals (grind "lmap" "len")))What grind does for the cons case:
- Simp: unfolds
lmap.eq_2andlen.eq_2to get1 + len(lmap f tl) = 1 + len tl - E-graph: internalizes all terms and the IH (
len(lmap f tl) = len tl) - Congruence closure:
X = Yimplies1 + X = 1 + Y→ goal closed
The string arguments "lmap" "len" tell grind which equation theorems to use. In Lean 4 this is by grind [lmap, len].
More properties grind handles:
;; Append is associative
(a/theorem app-assoc [xs :- (List Nat), ys :- (List Nat), zs :- (List Nat)]
(= (List Nat) (app (app xs ys) zs) (app xs (app ys zs)))
(induction xs) (all_goals (grind "app")))
;; Map distributes over append
(a/theorem map-app [f :- (arrow Nat Nat), xs :- (List Nat), ys :- (List Nat)]
(= (List Nat) (lmap f (app xs ys)) (app (lmap f xs) (lmap f ys)))
(induction xs) (all_goals (grind "lmap" "app")))
;; Append length = sum of lengths
(a/theorem app-len [xs :- (List Nat), ys :- (List Nat)]
(= Nat (len (app xs ys)) (+ (len xs) (len ys)))
(induction xs)
(all_goals (try (simp_all "app" "len" "Nat.add_assoc")))
(all_goals (try (grind "app" "len"))))The pattern is always: induction to split cases, grind to close each one.
Grind also handles constructor discrimination (different constructors can't be equal) and injection (same constructor implies equal fields):
;; nil and cons are different
(a/theorem cons-ne-nil [x :- Nat, xs :- (List Nat),
h :- (= (List Nat) (cons x xs) nil)]
False
(grind))
;; Same constructor → same fields
(a/theorem cons-inj [a :- Nat, b :- Nat, xs :- (List Nat), ys :- (List Nat),
h :- (= (List Nat) (cons a xs) (cons b ys))]
(= Nat a b)
(grind))These work because the kernel knows that List has exactly two constructors (nil and cons), and constructors are injective.
You can define your own algebraic data types:
(a/inductive Color [] (red) (green) (blue))
(a/inductive Tree [α Type]
(leaf)
(node [left (Tree α)] [val α] [right (Tree α)]))Type parameters (like α in Tree) are automatically inferred:
;; No need to write (Tree.node Nat ...) — α is inferred from val
(a/defn tree-sum [t (Tree Nat)] Nat
(match t (Tree Nat) Nat
(leaf 0)
(node [l v r] (+ v (+ ih_left ih_right)))))Structures compile to Clojure records:
(a/structure Point [] (x Nat) (y Nat))
(let [p (->Point 3 4)]
(println (:x p) (:y p))) ;; => 3 4Every a/defn and a/theorem goes through the same pipeline:
- Compile: your s-expression → CIC expression tree
- Type-check: the Java kernel infers the type and verifies it matches
- Extract (theorems): build a proof term from tactic steps
- Verify (theorems): the kernel independently checks the proof term
- Add to environment: the definition/theorem becomes available
- Compile to Clojure (functions): generate a normal
fnfor runtime use
The kernel is the independent arbiter. Tactics can be buggy, the surface syntax can have quirks, but if the kernel accepts the proof term, the theorem is valid. This is the same trust model as Lean 4 — and since we use the same type theory (CIC), a proof verified in Ansatz is valid in Lean 4 and vice versa.
- examples/verified_list.clj — a complete verified list library with 10+ proved properties
- examples/rbtree.clj — red-black tree with balance invariant proof
- examples/sorting.clj — verified merge sort with well-founded recursion
For the theory behind all this:
- Lean 4 for Clojurians — translation guide between Lean 4 and Ansatz syntax
- Theorem Proving in Lean 4 — the concepts are the same, only the syntax differs
- Natural Number Game — interactive tutorial for learning induction proofs (in Lean 4 syntax, but the ideas transfer directly)