Building proofs¶
One rule application is a single step. A proof is many of them fitted together, and it helps to picture how.
Proofs are dominoes¶
Think of the rules as pieces in a game of dominoes. Each piece has a top (its conclusion) and a bottom (its premises), and you can lay one piece against another only where they line up: a premise you still owe on one piece is answered by the conclusion of the next.
You start by laying down a piece whose top matches your goal. That piece may leave one open end (a single premise) that you extend with another piece, or it may branch into several open ends (several premises) — one for each subgoal. You keep laying pieces down each open end until every end is capped. And you cap an end with an axiom or an assumption — a piece with no bottom, nothing left to answer. When every branch is capped, the chain is complete and the proof closes.
That’s why the number of premises drives everything: a one-premise rule extends
a single line (then), a many-premise rule forks it (cases), and a
zero-premise fact caps the line it’s on.
Because each step hangs its subgoals beneath it and every branch ends in a cap, the finished structure is a tree: the goal at the root, rule applications for nodes, and axioms or assumptions for leaves. This is the proof tree, and it’s the thing the kernel actually checks. A proof written in Algae is just that tree, laid out in text.
A branching proof, end to end¶
Let’s prove that conjunction doesn’t care about order: from a proof of A ∧ B
we build B ∧ A. This uses all three conjunction rules at once — a nice
domino chain that forks and then caps each branch. We spell the rules out in the
buffer instead of import-ing them, so you can see exactly what each by
step is matching against:
rule and_intro(P Q : Prop)
⊢ P;
⊢ Q
────────────────────────
⊢ P ∧ Q
end;
rule and_left(P Q : Prop)
⊢ P ∧ Q
────────────────────────
⊢ P
end;
rule and_right(P Q : Prop)
⊢ P ∧ Q
────────────────────────
⊢ Q
end;
lemma and_comm(A B : Prop, both := A ∧ B)
⊢ B ∧ A;
proof
by and_intro(B, A) cases
case ⊢ B; by and_right(A, B) then ⊢ A ∧ B; by both; qed;
case ⊢ A; by and_left(A, B) then ⊢ A ∧ B; by both; qed;
qed;
qed;
Follow the dominoes:
The goal is
⊢ B ∧ A. Lay downand_intro— its conclusionP ∧ Qmatches withP = B,Q = A, soby and_intro(B, A). It has two premises, so the line forks into⊢ Band⊢ A— hencecases.Left branch, ``⊢ B``. Lay
and_right(conclusion⊢ Q, matched withQ = B). Its single premise is⊢ A ∧ B, so we extend withthen ⊢ A ∧ B;— and cap that end with our assumption,by both.Right branch, ``⊢ A``. Symmetric:
and_left(conclusion⊢ P, withP = A) leaves⊢ A ∧ B, capped againby both.
Both branches capped, both cases closed, qed. Notice how both := A ∧ B
— the whole conjunction we were handed — caps both branches; it’s evidence we
can cite as many times as we like.
Draw the same proof as a tree and its shape jumps out — the goal at the root,
each by a node that hangs its subgoals below, and every leaf an assumption
that caps its branch:
⊢ B ∧ A (the goal — root of the tree)
└─ by and_intro(B, A) two premises → the tree forks
├─ ⊢ B
│ └─ by and_right(A, B) one premise
│ └─ ⊢ A ∧ B
│ └─ by both leaf: assumption caps the branch
└─ ⊢ A
└─ by and_left(A, B) one premise
└─ ⊢ A ∧ B
└─ by both leaf: assumption caps the branch
Read top to bottom it’s the proof you wrote; read bottom to top it’s the logical
argument — every branch climbs from the goal up to a fact you already hold. That
tree is the proof; the proof … qed text is just one way of writing it down.
What the checker actually checks¶
The kernel (algae-kernel) is deliberately dim: it does not search for
proofs, and it will not fill anything in for you. For every by step it
performs exactly two checks:
The rule fits the goal. It instantiates the rule’s conclusion with your arguments and confirms it matches the current goal. (
and_intro(B, A)has conclusionB ∧ A— that had better be the goal.)You accounted for the premises. It instantiates the rule’s premises the same way and confirms the subgoals you continue with — your
thengoal, or yourcasegoals — are exactly those premises. (and_intro(B, A)demands premises⊢ Band⊢ A; your two cases must be precisely those.)
Get either wrong and the step is rejected, pointing at the mismatch. There is no third, hidden step where the checker “runs” anything or guesses — a proof is correct only when every domino lines up under those two checks, all the way down.
Everything is explicit — on purpose
Unlike Lean 4 or Rocq (Coq), Algae keeps all the proof information in the syntax, and that was a deliberate design decision. Rocq, for example, drives proofs with a macro/tactic language that generates proof terms — which enables proof search, automation, and a great deal of power. That power comes at two costs: it is harder to learn, and you cannot read the proof off the source. (Rocq can print the underlying term, but it was never meant to be read.) Algae is built on two principles instead: be simple, and be explicit — the context, the goal, and the rule being applied are all deliberately required in the syntax, right where you can see them.
Why so strict?
Because a checker that guesses is a checker you can’t trust. Every gap is one
you must close explicitly, which is exactly what makes a passing proof mean
something. When you don’t know the next piece, that’s what holes are
for — drop a by wip(?goal), check, and let the kernel tell you the open
end it’s still waiting on.
Your turn
Disjunction has the mirror shape: or_intro_left proves A ∨ B from just
A — a single premise, so then. Given a proof of A, prove
A ∨ B. The buffer inspects the step with a trailing ?; Check ▶ to
see what it leaves.
rule or_intro_left(P Q : Prop)
⊢ P
────────────────────────
⊢ P ∨ Q
end;
lemma weaken(A B : Prop, x := A)
⊢ A ∨ B;
proof
by or_intro_left(A, B)?;
wip;
Hint
or_intro_left(A, B) matches ⊢ A ∨ B and leaves the one premise
⊢ A. Drop the ?, continue then ⊢ A;, close by x;, and make
the terminator qed.