When tactics become a language
Contents
When tactics become a language#
A collection of tactics is a toolbox. It becomes a language when we can sequence tools, try alternatives, iterate, and direct work to particular goals. Peano Lab reaches that point without adding a single logical rule. Its tacticals are higher-order Python functions: they accept tactics and return a new tactic with the same transactional contract.
The pinned low-level type stays wonderfully small:
The string carries a primitive’s arguments. The surface parser first assembles a child such as
simp [IH]; tacticals then invoke that assembled child with an empty extra argument string.
For example, induction n; simp compiles to then(induction_n, simp). It applies induction to the
focused goal and simplification to every new induction obligation:
pa> pa prove forall n. 0 + n = n
pa> induction n; simp
pa> qed
The browser’s live tactical index
contains a checked script for every construct described below.
Six combinators, six precise meanings#
The surface grammar is small enough to state completely.
Form |
Meaning |
|---|---|
|
Run |
`t1 < |
> t2` |
|
Keep applying |
`first [t1 |
t2 |
|
Apply |
|
Apply |
then and all_goals snapshot the goals they promise to visit. They do not recursively chase
subgoals created by their right-hand or child tactic. This distinction prevents surprising loops
and makes command cost understandable. If recursive behaviour is desired, it must be said with
repeat or another explicit composition.
The following proof makes goal selection observable. focus 2 closes only the second conjunct;
all_goals then visits the one goal that existed at its own entry:
pa> pa prove (0 = 0) /\ (0 = 0)
pa> split
pa> focus 2 (refl <|> assumption)
pa> all_goals (first [assumption | refl])
pa> qed
repeat treats a child’s ordinary tactic failure as its normal stopping condition. A malformed
command or exhausted resource bound propagates instead of masquerading as successful termination.
The compound command succeeds with the last good state, as in this introduction spine:
pa> pa prove (0 = 0 -> 0 = 0) -> 0 = 0 -> 0 = 0
pa> repeat intro
pa> assumption
pa> qed
There are two extra safeguards. If a tactic succeeds without changing the logical goals or
substitution, repetition stops. If states cycle—as repeat symm can do—it stops rather than growing
an infinite tower of symmetric certificates. A final 256-step guard reports resource exhaustion as
an error; it is operational protection, not a logical verdict.
Focus follows holes, not list positions#
The difficult combinator is focus. Suppose the partial certificate is
\(C[\square_1,\square_2,\square_3]\) and the user writes focus 2 t. Reordering the visible goals and
running t on the front would be unsafe: the result might be inserted into \(\square_1\). Instead
Peano Lab builds a one-goal local state whose partial certificate is exactly Hole(id₂). After the
child succeeds, it replaces that same ID inside \(C\) and restores generated goals at the second
position.
The merge also propagates the child’s metavariable substitution through the whole proof. A witness chosen while focusing goal two may constrain goals one and three. This is why each combinator validates not just the goal list but the exact state class, hole invariant, original theorem, history prefix, and every substitution that existed before the child ran.
The implementation is intentionally comment-rich rather than clever; read
engine/tacticals.py
alongside its
contract tests.
One tactical expression is one transaction#
A child tactical can take several successful internal steps before a later child fails. Those
intermediate values never become the caller’s state. orelse hands its right branch the original
immutable value after an ordinary failure; first does the same for each candidate. Surface-syntax
errors abort immediately. If all choices fail, the caller still owns the exact input, and any
resource exhaustion remains a structured limit rather than being rewritten as ordinary failure.
On success, child history entries are hidden and the outer combinator records one Step. Thus one
explicit tactical expression is one undo, including induction n; simp or all_goals t. This is
both a usability rule and a semantic one: the compound unit the learner typed is the unit that
history rolls back. auto is deliberately different: it replays its winning primitive plan, so
each replayed primitive remains a separate undo step and a separate successful trace record.
The surface parser lives separately in
ui/prove.py.
It splits only outside parentheses and brackets. <|> is parsed before ;, while parentheses make
grouping explicit; first accepts |, comma, or <|> between choices. Session commands such as
undo and classical on cannot be nested as tactics because they change owner-level state rather
than certificate holes.
auto is bounded search followed by replay#
auto [depth] is related to the tactic language but is not a magical seventh logical rule. It
enumerates a deterministic menu of ordinary moves—simp, assumptions, introductions,
constructors, selected local equations through contextual simp sets, induction, and
applications—over immutable proof states. The
depth is proof-tree depth, so sibling obligations reuse the same allowance. A separate node budget
protects the browser.
Backtracking matters when siblings share a metavariable. A locally successful witness choice in the first branch may make the second impossible; the search generator must then resume the earlier choice and try another solution. At a complete leaf, an advisory kernel call discards malformed certificates. It is still not QED, because search owns neither the original statement nor the classical-mode authority.
Once a plan is found, auto throws away the speculative search tree and replays only the winning
primitive commands through the public dispatcher. You can watch it solve commutativity and still
require the ordinary owner-held final check:
pa> pa prove forall n m. n + m = m + n
pa> auto 5
pa> qed
If a finite candidate tree is exhausted, the result is “no proof found”; if a depth or node boundary
was hit, the result is explicitly “limit.” Neither means unprovable. The complete policy is in
engine/search.py.
Traces are executable semantics#
Every interactive tactic application—and every programmatic application given a logger—emits a
version-1 JSONL transition with canonical goals before and after, a zero-based focus, the tactic
text, and either status: "ok" or final error text. Failed applications are valuable training
examples, so they are recorded too—with unchanged before/after goals. A successful QED appends a
footer naming the theorem, certificate size, and tactic count.
For an explicitly written tactical, the record contains the complete surface command. Replaying
that text from the corresponding full session state must reproduce goals_after; rendered
goals_before alone is not a standalone snapshot because it omits the partial certificate,
substitution, history, original target, and classical authority. For a successful nonempty auto,
speculative branches are deliberately silent and only the winning primitive sequence is logged.
A failed auto, or a successful no-op on an already complete checked state, records auto itself.
The corpus therefore remains linear: it never claims that the prover jumped from one search branch
to another.
Metavariable aliases such as ?t1 stay stable for the whole session, control characters are escaped,
and JSON object fields have fixed insertion order. These are more than cosmetic promises. A learner
cannot infer a reliable state transition function if the printer gives one formula several names,
if focus is ambiguous, or if failures secretly mutate state. See the executable
trace contract
and its
tests.
The resulting architecture has three clean layers: tacticals organize untrusted certificate construction; replayable traces describe what happened; the independent kernel alone decides whether the final certificate proves the owner-held theorem. That is how a tiny collection of tactics becomes a useful language without becoming part of the trusted logic.