kernel design
The kernel is the trusted computing base of QED: the only code that must be correct for a theorem to be believed. This page explains the logic it implements, why its interface makes every other package untrusted, and how the soundness argument of the formal specification maps onto the code in src/kernel.
Design goal
A theorem prover is only as trustworthy as the code that can create theorems. QED follows the LCF approach of Milner’s Edinburgh LCF and its descendants HOL Light and HOL4:11 R. Milner, “LCF: A way of doing proofs with a machine”, 1979; J. Harrison, “HOL Light: An overview”, TPHOLs 2009. QED follows HOL Light’s choice of primitive rules most closely. theorems are values of an abstract type whose only constructors are the inference rules of the logic. Parsers, tactics, proof search and the command-line tool may contain any number of bugs, but a bug there can only make a proof fail, never make a false statement a theorem.
The kernel therefore has three goals:
- implement higher-order logic (HOL) with a small, fixed set of rules that can be read and checked by hand;
- make the theorem type impossible to forge from outside the package;
- grow the theory only through extensions that are provably conservative, and record each one for audit.
Mathematical background
Types
Types are generated by type variables and type constructors with fixed arities:
The constructors bool (arity 0), fun (arity 2, written ) and ind (arity 0) are built in. A type substitution maps type variables to types and acts homomorphically. A type is an instance of a schema , written , when for some ; ty_is_instance_of decides this by first-order matching.
Terms
Terms are those of the simply typed λ-calculus over a signature of constants:
A variable is a pair of a name and a type. A constant occurrence is legal when is declared with schema and . The typing judgement is the usual one:
Typing is syntax-directed and every well-typed term has exactly one type, so type_of is a total function into HolType? that runs in linear time.
The only logical constants of the core are equality and choice:
Every other connective is a definition in terms of these two, made by the logic package through the DefOK gate; the logic design gives the definitions. Keeping connectives out of the kernel keeps the kernel small: it knows nothing about or .
α-equivalence and De Bruijn terms
Two terms are α-equivalent when they differ only in the names of bound variables: . The rules of HOL must not depend on bound names, so the kernel works on a nameless representation. A De Bruijn term replaces each bound occurrence by the number of binders between it and its binder:22 N. G. de Bruijn, “Lambda calculus notation with nameless dummies”, Indagationes Mathematicae 34, 1972.
The conversion (to_db_term) satisfies , so α-equivalence becomes structural equality (db_term_eq). QED’s De Bruijn terms are typed: bound occurrences and binders keep their types. Two consequences follow. Abstractions over different types never collapse, since for . And a term such as , whose inner variable has the name of the binder but another type, has no conversion at all: to_db_term returns None and every rule reports BoundaryFailure. HOL Light reads the inner x as a separate free variable; QED rejects the term, so that a binder name always refers to one variable.
Substitution and β-reduction
Write for the shift that adds to every index in , and for the replacement of index by , shifting as it passes under binders:
Shifts and replacement act homomorphically on applications and leave free variables and constants alone. β-contraction of is then
Why this cannot capture: inside , index refers to the binder being removed and indices to binders outside it. Shifting up by one before inserting it makes every free index of skip the binder that is about to disappear, and the replacement shifts again under each inner binder, so an index of always counts the binders that actually surround it. After replacement no occurrence of index remains, because each one was replaced, so the final shift down by one is defined and turns the indices that pointed past the removed binder back into their original values. The named counterpart is the capture-avoiding substitution ; the specification states this correspondence as the lemma “Well-Scoped Beta Contraction Safety”. The kernel computes every index with overflow checks and reports CapacityExceeded instead of wrapping.
Substitution for free variables (db_subst_free_parallel, used by INST) is simpler: a free variable is a name, never an index, and the inserted term is shifted by the current binder depth, which is a no-op for a term with no loose indices. Capture is impossible by construction, which is why INST needs no renaming step.
Sequents and theorems
A theorem is a sequent : a finite set of propositions (terms of type bool) and a proposition . The kernel stores and as De Bruijn terms, so is literally a set of α-equivalence classes: inserting a hypothesis that is α-equivalent to one already present does nothing (db_hyps_union).
The intended meaning is the standard semantics of HOL: types denote non-empty sets, denotes , denotes the full set of functions, denotes identity and denotes a choice function. A sequent is valid when every model and every valuation of the free variables that make all of true also make true.
Design decisions
The theorem type is abstract
Problem. If code outside the kernel could construct a Thm, any bug or shortcut in that code could produce a false theorem.
Options. An LCF-style abstract type; proof terms checked by a separate checker (the approach of Coq and Lean); or a trusted record with a “verified” flag.
Choice. Thm is declared type Thm in the interface: its fields are private and it has no public constructor. The only functions that return a Thm are the eleven rule functions (refl_checked to inst_checked, plus add_assum_checked), the gates ks_define_const_thm and ks_specify_const, the stored-theorem readers ks_definition_theorem, ks_typedef_contract and ks_ind_infinity_axiom, and thm_bind_const_ids, which returns its argument with constant identities recorded after checking it. MoonBit enforces this at compile time.
Why. With an abstract type the trusted base is exactly this package. Proof terms would add a second trusted component, the checker, and a large proof object per theorem; QED’s specification makes the LCF discipline normative (obligation “Interface safety”) and checks it by inspecting the interface file.
HOL Light’s primitive rules
Problem. Choose the rules that generate all theorems.
Choice. The ten rules of HOL Light, the smallest standard basis for HOL with equality as the only primitive connective:
Matching of premises (the middle term of TRANS, the antecedent of EQ_MP) is up to α-equivalence and ignores constant identities (db_term_logical_eq), because both theorems have already been checked against the current state.
One deviation: QED’s BETA accepts any redex , whereas HOL Light’s primitive BETA only accepts and derives the general form with INST. The general form is a derived rule in HOL Light, so this adds no theorems; it saves the kernel a renaming step and is exactly the rule stated in the specification.
Why HOL and not dependent types. HOL has a simple, well-understood set-theoretic semantics, a kernel of a few hundred lines in HOL Light, and decades of experience showing that the ten rules suffice for mathematics when combined with definitions. The kernel stays small enough for its soundness argument to be read in full, which is the point of a kernel-first design.
Weakening is provided natively
add_assum_checked implements weakening, gives . It is not one of the ten rules and the specification does not list it, but it is a derived rule, so it adds no theorems:
The hypothesis set of line 4 is : the part is contained in , and . The derivation works whether or not or . The native rule is a shortcut used by replay in the logic package to make hypothesis sets match exactly.
A named boundary over a De Bruijn core
Problem. Users and the frontend think in named terms; the rules must be name-independent.
Options. Named terms with explicit renaming (HOL Light); locally nameless terms; De Bruijn terms everywhere.
Choice. The interface takes and returns named Term values; every rule converts its inputs with to_db_term, works on DbTerm, and converts back with from_db_term only when a caller asks for hypotheses or a conclusion. A failed conversion is the error BoundaryFailure, not a derivation.
Why. α-equivalence becomes equality, substitution needs no fresh names, and hypothesis sets are sets of α-classes for free. The cost is that terms read back from a theorem carry generated binder names (_b0, _b1, …), so callers compare with term_alpha_eq. The specification proves the commutation square that justifies the boundary: lowering, running the De Bruijn rule and lifting gives a result α-equivalent to running the named rule.
Every rule is checked against a state
Problem. Constants are declared in scopes and can be shadowed. A theorem about the constant c proved in one scope must not be reused after an inner scope declares a different c.
Choice. Every rule takes the KernelState and runs ensure_thm_admissible on its premises and on its result. A theorem records the identity (ConstId) of each constant it mentions. It is admissible in a state when each recorded identity is the one the state resolves the name to, each constant occurrence is an instance of the declared schema, each type uses only admitted constructors, and a definition theorem still agrees with its definition.
Why. Name lookup changes as scopes are pushed and popped, but a recorded identity does not. Freezing identities makes resolution stable under scope mutation (the specification’s “Resolution Freeze” theorem), and the check turns a stale theorem into an InvalidInstantiation failure instead of a silent change of meaning. The tutorial shows a theorem that is rejected inside a shadowing scope and accepted again after the scope is popped.
Extensions go through gates
The theory grows in three ways, each guarded by a gate that checks side conditions and appends an ExtensionCert.
DefOK, constant definition. ks_define_const(c, \tau, t) adds a constant and the theorem . Each side condition rules out a known way to break conservativity:
| Condition | Error | Counterexample it prevents |
|---|---|---|
| is closed | DefinitionNotClosed | would give , then INST gives and so for all . |
| does not occur in , even through earlier definitions | DefinitionIsCyclic | would give , a contradiction. |
GhostTypeVariable | with is true at and false at , yet both instances are the same constant . | |
| is fresh | DefinitionAlreadyExists | Two definitions of one name would give and , so . |
Under these conditions a definition is conservative: replacing every occurrence of by maps each proof in the extended theory to a proof in the old theory, and maps a theorem that does not mention to itself. The specification proves this as “Definition-level conservativity”.
TypeDefOK, type definition. ks_register_type_definition admits a type in bijection with , given a theorem . The witness matters because HOL types denote non-empty sets: a type defined by an empty predicate would have no model, and the axiom applied to the empty type would make the theory inconsistent. The gate requires the predicate’s type variables to be among the parameters for the same reason that DefOK forbids ghost type variables, and returns three contract theorems:
The first two say that is injective with image inside ; the third says every element of is in the image. Together they are HOL Light’s characterisation of a type bijection, with the equivalence split into the two directions.
SpecOK, constant specification. ks_specify_const introduces with the property , given . It is not a new primitive: it defines through DefOK and returns , which follows from the choice axiom
instantiated at . Because the extension is a definition, its conservativity follows from that of DefOK; the state records both a DefOK and a SpecOK certificate.
Infinity anchor. HOL needs an infinite type for arithmetic. ks_register_ind_infinity_axiom records a theorem about ind that plays this role, but only accepts a theorem that already exists; it marks the model-class restriction of the specification without adding a theorem.
Results, not exceptions
Every kernel function returns Result or Option; none aborts on bad input. A rule that cannot apply says why, with a constructor of LogicError or SigError, and the caller decides what to do. This is what makes the frontend fail closed: the tactics and prover packages turn these values into structured diagnostics, and no path turns a failed rule into a theorem.
Correctness and invariants
Why soundness reduces to the kernel
Call a theorem value sound when its sequent is valid in every model of the current theory. The argument has three steps.
1. Every rule preserves validity. For each primitive rule, valid premises give a valid conclusion. Two cases show the pattern.
ABS. Let be a model and a valuation that satisfies . Since , every valuation also satisfies , so by validity of the premise for every . Hence
by function extensionality in the standard model. Without the side condition the step “every satisfies ” fails: from one could derive , which is false whenever holds.
DEDUCT_ANTISYM_RULE. Let satisfy . If , then satisfies (the only hypothesis that may have been removed from is ), so . Symmetrically implies . Two booleans that imply each other are equal, so .
The remaining rules follow the same way: REFL and TRANS from reflexivity and transitivity of identity, MK_COMB from congruence of application, BETA from the substitution lemma , EQ_MP from the meaning of on booleans, ASSUME trivially, and INST and INST_TYPE because a valid sequent is valid under every valuation and every interpretation of type variables. The specification proves each case (“Rule-level preservation”).
2. Every extension preserves consistency. DefOK, TypeDefOK and SpecOK are conservative, as argued above: every model of the old theory extends to a model of the new one, so no new sentence in the old language becomes provable.
3. Interface safety. Because Thm is abstract, every Thm that exists at run time is the root of a finite derivation tree whose nodes are rule applications and gate outputs. Induction on the depth of that tree, using steps 1 and 2, shows that every Thm is sound.
Step 3 is a property of the code, not of the logic, and it is why nothing outside src/kernel needs to be trusted: the logic, tactics and prover packages can only call the functions in the kernel API, so whatever they compute, any Thm they return has a derivation. The specification states the six obligations and their dependencies; the conformance pack in formal_verification/ checks the paper side in Lean.
Invariants maintained by the code
- A
Thmstores its hypotheses as an α-deduplicated list and its conclusion as a De Bruijn term; every rule result passesensure_thm_admissiblein the state it was built in. KernelStateis persistent. A gate returns a new state; the base state stays valid, soks_conservative_replay_ok(base, extended, th)can re-checkthagainst both.- Constant identities are allocated from a counter in the theory state and never reused, even after a scope is popped.
- Definition heads, type-definition heads and the infinity anchor are recorded in the theory history, which
ks_pop_scopedoes not touch: a name, once defined, cannot be defined again.
Complexity
Each rule is linear in the size of its premises, except for hypothesis-set union, which compares hypotheses pairwise and is quadratic in their number. Admissibility checks walk the theorem once per constant occurrence and look up names in the scoped signature, linear in the number of declarations. Proof sizes in the shipped subset are small; the kernel favours checks that are easy to audit over asymptotic speed.
Alternatives rejected
- Named terms with renaming, as in HOL Light. Every rule would need a correct renaming function, which is a classic source of kernel bugs. The De Bruijn core avoids renaming entirely.
- Connectives as kernel primitives. Adding , or as primitive constants with their own rules would enlarge the kernel and its soundness proof. They are definitions over instead.
- Exceptions for rule failure. HOL Light raises
Failure. Results make every failure visible in the type and keep the frontend from accidentally catching and ignoring one. - Unchecked rules with a separate validation pass. Checking admissibility only at the end would let an inadmissible intermediate theorem feed later steps. Every rule checks its inputs and output instead.
- Arbitrary axioms. There is no function that turns a term into a theorem. The only non-derived theorems are definitions and type-definition contracts, both produced by gates with conservativity conditions.
Boundaries
- The kernel does not parse text, elaborate names or run tactics; those are the elab, parser and tactics packages, and none of them is trusted.
- It implements no connective other than equality and choice, and no quantifier syntax; the logic package defines the rest.
- It does not store proved theorems by name. Theorem names are a frontend concern.
- It has no metavariables and no incomplete theorems: a
holein a proof script never reaches the kernel. - It does not prove its own soundness. The argument above and in the specification is a paper proof;
formal_verification/aligns the specification with Lean, not the MoonBit source. - It does not provide the choice axiom as a theorem.
@is declared and used bySpecOK, but no public function returns .