Kernel Formal Specification
This document is the contract the implementation must satisfy. Every typing rule, compute rule, and conversion rule is stated precisely. Every test is derived from this spec. Every invariant the kernel must maintain is listed.
The spec uses standard type-theoretic notation. No Nix code appears here — this document is reviewable by anyone who reads dependent type theory, regardless of implementation language.
1. Trust Model
The kernel has three layers with strictly decreasing trust requirements.
Layer 0 — Trusted Computing Base (TCB). The evaluator, quotation, and conversion checker. Pure functions. No side effects. No imports from the effect system. Bugs here compromise soundness. Every line must be auditable.
eval : Env × Tm → Valquote : ℕ × Val → Tmconv : ℕ × Val × Val → Bool
Layer 1 — Semi-trusted. The bidirectional type checker. Uses the TCB and sends effects for error reporting. Bugs here may produce wrong error messages or reject valid terms, but cannot cause unsoundness (the TCB rejects ill-typed terms independently).
check : Ctx × Tm × Val → Tminfer : Ctx × Tm → Tm × ValcheckTypeLevel : Ctx × Tm → Tm × ℕ
Layer 2 — Untrusted. The elaborator. Translates surface syntax (named variables, implicit arguments, level inference, eta-insertion) into fully explicit core terms. Can have arbitrary bugs without compromising safety — the kernel verifies the output.
Failure modes
| Condition | Response | Rationale |
|---|---|---|
| Kernel invariant violation |
throw (crash) | TCB may be buggy; cannot trust own output |
| User type error |
Effect typeError | Normal operation; handler decides policy |
| Normalization budget exceeded |
throw (crash) |
Layer 0 has no effect access; tryEval catches it |
| Unknown term tag |
throw (crash) | Exhaustiveness violation = kernel bug |
2. Syntax
2.1 Terms (Tm)
The core term language. All binding uses de Bruijn indices. Name annotations are cosmetic (for error messages only).
Tm ::=
-- Variables and binding
| Var(i : ℕ) -- de Bruijn index
| Let(n : Name, A : Tm, t : Tm, u : Tm) -- let n : A = t in u
-- Functions
| Pi(n : Name, A : Tm, B : Tm) -- Π(n : A). B
| Lam(n : Name, A : Tm, t : Tm) -- λ(n : A). t
| App(t : Tm, u : Tm) -- t u
-- Pairs
| Sigma(n : Name, A : Tm, B : Tm) -- Σ(n : A). B
| Pair(a : Tm, b : Tm) -- (a, b)
| Fst(t : Tm) -- π₁ t
| Snd(t : Tm) -- π₂ t
-- Unit
| Unit -- ⊤
| Tt -- tt
-- Empty
| Empty -- ⊥
| Absurd(P : Tm, x : Tm) -- empty-type eliminator
-- Bootstrap coproduct, private to descPlus interpretation
| BootSum(A : Tm, B : Tm) -- A + B
| BootInl(A : Tm, B : Tm, t : Tm) -- inl t
| BootInr(A : Tm, B : Tm, t : Tm) -- inr t
| BootSumElim(A : Tm, B : Tm, P : Tm, l : Tm, r : Tm, s : Tm)
-- private eliminator for descPlus payloads
-- Bootstrap identity, private to descRet/Lift/index transport
| BootEq(A : Tm, a : Tm, b : Tm) -- internal Id_A(a, b)
| BootRefl -- internal refl
| BootJ(A : Tm, a : Tm, P : Tm, pr : Tm, b : Tm, eq : Tm)
-- private identity eliminator
| Funext -- function extensionality axiom
-- Propositional truncation
| Squash(A : Tm) -- proof-irrelevant truncation
| SquashIntro(a : Tm) -- introduction into Squash
| SquashElim(A : Tm, B : Tm, f : Tm, x : Tm)
-- recTrunc: A -> Squash B, Squash A -> Squash B
-- Levels (Tarski-style sort of universe levels — see §6.6, §8.5)
| Level -- the Level sort itself, lives at U(0)
| LevelZero -- 0
| LevelSuc(k : Tm) -- successor: k+1
| LevelMax(a : Tm, b : Tm) -- join in the level semilattice
-- Universes (level-indexed, k : Level)
| U(k : Tm) -- Type_k
-- Descriptions (universe-polymorphic; see §7.6)
| Desc(K : Tm, I : Tm) -- Desc^K I — descriptions over index type I
| DescRet(j : Tm) -- ret(j) — leaf returning at index j
| DescArg(K : Tm, S : Tm, T : Tm) -- arg^K S T — non-recursive Π over S : U(K)
| DescRec(j : Tm, D : Tm) -- rec(j) D — recursive child at index j, then D
| DescPi(K : Tm, S : Tm, f : Tm, D : Tm)
-- π^K S f D — recursive Π over S : U(K), indexed by f
| DescPlus(A : Tm, B : Tm) -- A + B — first-class binary coproduct of descriptions
| DescDescApp(I : Tm, L : Tm) -- canonical descDesc I L reference
| InterpD(level : Tm, I : Tm, D : Tm, X : Tm, i : Tm)
-- interpretation of D at index i
| AllD(level : Tm, I : Tm, D : Tm, K : Tm, X : Tm, M : Tm, i : Tm, d : Tm)
-- all recursive positions satisfy M
| EverywhereD(level : Tm, I : Tm, D : Tm, K : Tm, X : Tm, M : Tm,
ih : Tm, i : Tm, d : Tm)
-- builds AllD evidence from ih
| DescInd(D : Tm, motive : Tm, step : Tm, i : Tm, scrut : Tm)
-- generic μ-induction over D at index i
-- μ-types (description-induced datatypes)
| Mu(I : Tm, D : Tm, i : Tm) -- μ I D i — the i-th type in the family classified by D
| DescCon(D : Tm, i : Tm, payload : Tm)
-- introduction at index i with payload : interp(D, i)
-- Annotations
| Ann(t : Tm, A : Tm) -- (t : A); may carry trusted/label sidecars
-- Lift
| Lift(l : Tm, m : Tm, eq : Tm, A : Tm)
| LiftIntro(l : Tm, m : Tm, eq : Tm, A : Tm, a : Tm)
| LiftElim(l : Tm, m : Tm, eq : Tm, A : Tm, x : Tm)
-- Axiomatized primitive types
| String -- string type
| Int -- integer type
| Float -- float type
| Attrs -- attribute set type
| Path -- path type
| Derivation -- derivation attrset type
| Function -- opaque function type
| Any -- dynamic/any type
-- String operations
| StrEq(lhs : Tm, rhs : Tm) -- string equality: lhs == rhs → H.bool (derived)
-- Primitive literals
| StringLit(s) -- string literal
| IntLit(n) -- integer literal
| FloatLit(f) -- float literal
| AttrsLit -- attribute set literal
| PathLit -- path literal
| DerivationLit -- derivation attrset literal
| FnLit -- opaque function literal
| AnyLit -- any literal
-- Opaque lambda trust boundary for extracted Nix functions
| OpaqueLam(fnBox, piTy : Tm)
-- Closed-Val splice (two-level TT reflection)
| LitVal(v : Val) -- opaque carrier; eval is identity on v
Public Nat, List, Sum, Bool, Fin, Vec, Eq, and W are
not primitive core syntax.
The HOAS prelude generates them as descriptions:
H.nat = Mu(Unit, NatDT.D, Tt)
H.listOf A = Mu(Unit, ListDT.D A, Tt)
H.sum A B = Mu(Unit, SumDT.D 0 A B, Tt)
H.eq A a b = Mu(A, EqDT.D A a, b)
Their constructors and eliminators elaborate to DescCon and
DescInd applications through the datatype macro layer. The kernel
therefore exposes one public induction principle for data: DescInd.
The bootstrap coproduct and bootstrap identity above remain internal
support for description interpretation, DescRet, Lift, and index
transport.
2.2 Binding convention
In Pi(n, A, B), Lam(n, A, t), Sigma(n, A, B), and Let(n, A, t, u):
the body (B, t, or u) binds one variable. Index 0 in the body
refers to the bound variable. All other indices shift by 1.
Eliminators take their motives as ordinary function terms, not as
implicit binders. Generated public eliminators build typed HOAS
applications to datatype-specific eliminator functions; the core
eliminator underneath is DescInd.
2.3 De Bruijn index conventions
Indices count inward from the use site: 0 = most recent binder.
Example: λ(x : A). λ(y : B). x is Lam(x, A, Lam(y, B, Var(1))).
3. Values (Semantic Domain)
Values are the result of evaluation. They use de Bruijn levels (counting outward from the top of the context) instead of indices.
Val ::=
-- Functions
| VPi(n : Name, A : Val, cl : Closure) -- Π type
| VLam(n : Name, A : Val, cl : Closure) -- λ abstraction
-- Pairs
| VSigma(n : Name, A : Val, cl : Closure) -- Σ type
| VPair(a : Val, b : Val) -- pair value
-- Unit
| VUnit
| VTt
-- Empty
| VEmpty
-- Bootstrap coproduct, private to descPlus interpretation
| VBootSum(A : Val, B : Val)
| VBootInl(A : Val, B : Val, v : Val)
| VBootInr(A : Val, B : Val, v : Val)
-- Bootstrap identity, private to descRet/Lift/index transport
| VBootEq(A : Val, a : Val, b : Val)
| VBootRefl
| VFunext
-- Propositional truncation
| VSquash(A : Val)
| VSquashIntro(a : Val)
-- Levels (Tarski-style sort of universe levels — see §6.6, §8.5)
| VLevel -- the Level sort itself
| VLevelZero -- 0
| VLevelSuc(pred : Val) -- successor
| VLevelMax(lhs : Val, rhs : Val) -- join
-- Universes (level-indexed, k : VLevel)
| VU(k : Val)
-- Descriptions (universe-polymorphic; see §7.6)
| VDesc(K : Val, I : Val) -- Desc^K I
-- Description constructors are encoded as VDescCon inhabitants of
-- μ Unit (descDesc I K) tt. Evaluation projects them through the private
-- DViewRet/DViewArg/DViewRec/DViewPi/DViewPlus semantic view.
-- μ-types
| VMu(I : Val, D : Val, i : Val) -- μ I D i
| VDescCon(D : Val, i : Val, d : Val) -- introduction at index i with payload d
| VInterpD(level : Val, I : Val, D : Val, X : Val, i : Val)
| VAllD(level : Val, I : Val, D : Val, K : Val, X : Val, M : Val, i : Val, d : Val)
| VEverywhereD(level : Val, I : Val, D : Val, K : Val, X : Val, M : Val,
ih : Val, i : Val, d : Val)
-- Lift
| VLift(l : Val, m : Val, eq : Val, A : Val)
| VLiftIntro(l : Val, m : Val, eq : Val, A : Val, a : Val)
-- Axiomatized primitive types
| VString | VInt | VFloat | VAttrs | VPath | VDerivation | VFunction | VAny
-- Primitive literal values
| VStringLit(s) | VIntLit(n) | VFloatLit(f)
| VAttrsLit | VPathLit | VDerivationLit | VFnLit | VAnyLit
| VOpaqueLam(fnBox, piTy : Val)
-- Neutrals (stuck computations)
| VNe(level : ℕ, spine : [Elim])
Elim ::=
| EApp(v : Val)
| EFst
| ESnd
| EBootSumElim(A : Val, B : Val, P : Val, l : Val, r : Val)
| EBootJ(A : Val, a : Val, P : Val, pr : Val, b : Val)
| EStrEq(arg : Val)
| EAbsurd(P : Val)
| EDescInd(D : Val, motive : Val, step : Val, i : Val)
| EInterpD(level : Val, I : Val, X : Val, i : Val)
| EAllD(level : Val, I : Val, K : Val, X : Val, M : Val, i : Val, d : Val)
| EEverywhereD(level : Val, I : Val, K : Val, X : Val, M : Val,
ih : Val, i : Val, d : Val)
| ELiftElim(l : Val, m : Val, eq : Val, A : Val)
| ESquashElim(A : Val, B : Val, f : Val)
Closure ::= (env : Env, body : Tm)
Env ::= [Val] -- list indexed by de Bruijn index
3.1 Level/index relationship
De Bruijn levels count from the outermost binder: 0 = first-ever bound variable. Levels are stable under context extension.
Conversion between index and level:
index = depth - level - 1
level = depth - index - 1
where depth is the current binding depth (length of the context).
3.2 Fresh variables
A fresh variable at depth d is VNe(d, []) — a neutral with
level d and empty spine. Used in conversion checking to compare
under binders.
3.3 Closure instantiation
instantiate((env, body), v) = eval([v] ++ env, body)
4. Evaluation Rules
eval(ρ, t) interprets term t in environment ρ, producing
a value. All rules are deterministic.
4.1 Variables and let
eval(ρ, Var(i)) = ρ[i]
eval(ρ, Let(n, A, t, u)) = eval([eval(ρ, t)] ++ ρ, u)
eval(ρ, Ann(t, A)) = eval(ρ, t)
4.2 Functions
eval(ρ, Pi(n, A, B)) = VPi(n, eval(ρ, A), (ρ, B))
eval(ρ, Lam(n, A, t)) = VLam(n, eval(ρ, A), (ρ, t))
eval(ρ, App(t, u)) = vApp(eval(ρ, t), eval(ρ, u))
where vApp performs beta reduction or accumulates:
vApp(VLam(n, A, cl), v) = instantiate(cl, v)
vApp(VNe(l, sp), v) = VNe(l, sp ++ [EApp(v)])
vApp(_, _) = THROW "kernel bug: vApp on non-function"
4.3 Pairs
eval(ρ, Sigma(n, A, B)) = VSigma(n, eval(ρ, A), (ρ, B))
eval(ρ, Pair(a, b)) = VPair(eval(ρ, a), eval(ρ, b))
eval(ρ, Fst(t)) = vFst(eval(ρ, t))
eval(ρ, Snd(t)) = vSnd(eval(ρ, t))
where:
vFst(VPair(a, b)) = a
vFst(VNe(l, sp)) = VNe(l, sp ++ [EFst])
vFst(_) = THROW "kernel bug: vFst on non-pair"
vSnd(VPair(a, b)) = b
vSnd(VNe(l, sp)) = VNe(l, sp ++ [ESnd])
vSnd(_) = THROW "kernel bug: vSnd on non-pair"
4.4 Generated data families
Natural numbers, lists, public sums, and public equality are prelude
datatypes generated from descriptions. Their types evaluate to
VMu I D i; their constructors evaluate to VDescCon D i payload;
their eliminators elaborate to DescInd over the generated description.
H.nat == μ Unit NatDT.D tt
H.zero == descCon NatDT.D tt <zero payload>
H.succ n == descCon NatDT.D tt <succ payload n>
H.listOf A == μ Unit (ListDT.D A) tt
H.nil A == descCon (ListDT.D A) tt <nil payload>
H.cons A h t == descCon (ListDT.D A) tt <cons payload h t>
H.sum A B == μ Unit (SumDT.D 0 A B) tt
H.eq A a b == μ A (EqDT.D A a) b
The generated constructor payloads still use private bootstrap coproduct/identity values where the description interpretation requires them. These are representation details, not public eliminators.
Deep generated natural/list values are stack-safe through the constructor
flattening and desc-con trampoline paths, rather than through primitive
nat/list evaluator cases.
4.5 Description interpretation, Lift, Squash, and Funext
The current kernel includes several small primitives used by generated datatypes and proof-oriented APIs:
eval(ρ, DescDescApp(I,L)) = tagged descDesc value
eval(ρ, InterpD(level,I,D,X,i)) = vInterpD(level,I,D,X,i)
eval(ρ, AllD(level,I,D,K,X,M,i,d)) = vAllD(level,I,D,K,X,M,i,d)
eval(ρ, EverywhereD(level,I,D,K,X,M,ih,i,d)) =
vEverywhereD(level,I,D,K,X,M,ih,i,d)
eval(ρ, Lift(l,m,eq,A)) = vLiftF(l,m,eq,A)
eval(ρ, LiftIntro(l,m,eq,A,a)) = vLiftIntroF(l,m,eq,A,a)
eval(ρ, LiftElim(l,m,eq,A,x)) = vLiftElimF(l,m,eq,A,x)
eval(ρ, Squash(A)) = VSquash(eval(ρ,A))
eval(ρ, SquashIntro(a)) = VSquashIntro(eval(ρ,a))
eval(ρ, SquashElim(A,B,f,x)) = vSquashElim(eval(ρ,A), eval(ρ,B), eval(ρ,f), eval(ρ,x))
eval(ρ, Funext) = VFunext
eval(ρ, OpaqueLam(fnBox, piTy)) = VOpaqueLam(fnBox, eval(ρ,piTy))
eval(ρ, LitVal(v)) = v
LitVal v is the splice operator of two-level type theory (Kovács,
"Staged Compilation with Two-Level Type Theory", POPL 2024;
Annenkov–Capriotti–Kraus–Sattler 2019): a closed semantic value
reflected into the syntactic domain. The eval rule discards the
environment, so the carried v must be closed (free de Bruijn levels
would never resolve). Quotation reads through to the underlying value
— LitVal is invisible at the Val layer, so conv, quote, and the
ι/β/η rules operate on v directly without any new transparency rule.
The kernel uses LitVal as the canonical Val→Tm lift on paths that
would otherwise call quote 0 v on a value destined for re-evaluation
(notably the effect-handler bridge in
src/experimental/desc-interp/trampoline.nix). Reflection avoids
the O(size v) structural walk of quote, which would compound across
iterated bridge steps.
Lift l m eq A collapses definitionally to A when l and m are
level-convertible; nested Lifts compose; lower (lift a) reduces to
a; and lift (lower x) eta-reduces on stuck neutrals. The eq
witness is irrelevant for conversion once the levels and underlying
type match.
Squash A is proof-irrelevant. Any two SquashIntro inhabitants at a
shared Squash A type are definitionally equal, and neutral inhabitants
of Squash A convert against introductions by the shared-type invariant.
DescDescApp stamps a canonical reference on the generated
descDesc I L value so quotation and conversion can avoid descending
into the self-describing universe spiral. InterpD, AllD, and
EverywhereD are the kernel primitives behind description
interpretation and the generated desc-ind iota rule.
4.6 Unit
eval(ρ, Unit) = VUnit
eval(ρ, Tt) = VTt
Unit has no eliminator in the core. The kernel implements ⊤-η: any
neutral of type ⊤ converts against VTt (see §6.3). Sound in the
type-free conv because conv is always called on values sharing a type;
if one side is VTt, the shared type is ⊤ and the neutral's only
inhabitant is Tt.
4.6′ Empty
eval(ρ, Empty) = VEmpty
Empty has no introduction. The eliminator Absurd (§4.6″) discharges
any neutral of type Empty.
4.6″ Absurd
eval(ρ, Absurd(P, x)) = vAbsurd(eval(ρ, P), eval(ρ, x))
vAbsurd(P, VNe(l, sp)) = VNe(l, sp ++ [EAbsurd(P)])
vAbsurd(P, _) = THROW "kernel bug: vAbsurd on non-neutral"
Absurd(P, x) is the unique map from the initial object to any type
P at any universe level. It is well-typed only when x : Empty;
since Empty has no canonical inhabitants, x is always neutral in
sound code. The non-neutral case is a kernel invariant violation —
THROW rather than silently propagate, matching vFst/vBootJ
hygiene.
There is no β-rule for Absurd: it only fires on neutrals, and no
canonical inhabitant of Empty exists to trigger reduction. Two
Absurd redexes on the same neutral with definitionally equal P
are conv-equal via the EAbsurd spine frame (§6.4).
4.7 Bootstrap coproduct
eval(ρ, BootSum(A, B)) = VBootSum(eval(ρ, A), eval(ρ, B))
eval(ρ, BootInl(A, B, t)) = VBootInl(eval(ρ, A), eval(ρ, B), eval(ρ, t))
eval(ρ, BootInr(A, B, t)) = VBootInr(eval(ρ, A), eval(ρ, B), eval(ρ, t))
eval(ρ, BootSumElim(A,B,P,l,r,s)) =
vBootSumElim(eval(ρ,A), eval(ρ,B), eval(ρ,P), eval(ρ,l), eval(ρ,r), eval(ρ,s))
where:
vBootSumElim(A, B, P, l, r, VBootInl(_, _, v)) = vApp(l, v)
vBootSumElim(A, B, P, l, r, VBootInr(_, _, v)) = vApp(r, v)
vBootSumElim(A, B, P, l, r, VNe(k, sp)) =
VNe(k, sp ++ [EBootSumElim(A, B, P, l, r)])
vBootSumElim(_, _, _, _, _, _) =
THROW "kernel bug: vBootSumElim on non-boot-sum"
This coproduct is private to descPlus interpretation. Public H.sum,
H.inl, H.inr, and H.sumElim route through generated SumDT.
4.8 Bootstrap identity
eval(ρ, BootEq(A, a, b)) = VBootEq(eval(ρ, A), eval(ρ, a), eval(ρ, b))
eval(ρ, BootRefl) = VBootRefl
eval(ρ, BootJ(A, a, P, pr, b, eq)) =
vBootJ(eval(ρ,A), eval(ρ,a), eval(ρ,P), eval(ρ,pr), eval(ρ,b), eval(ρ,eq))
where:
vBootJ(A, a, P, pr, b, VBootRefl) = pr
vBootJ(A, a, P, pr, b, VNe(l,sp)) =
VNe(l, sp ++ [EBootJ(A, a, P, pr, b)])
vBootJ(_, _, _, _, _, _) = THROW "kernel bug: vBootJ on non-refl"
This identity substrate is private to descRet, Lift witnesses, index
transport, and no-confusion helpers. Public H.eq, H.refl, and H.j
route through generated EqDT.
4.9 Universes
eval(ρ, U(i)) = VU(i)
4.10 Axiomatized primitives
Type formers evaluate to their corresponding values. Literals carry their payload through. No computation, no recursion — these are axiomatized constants.
eval(ρ, String) = VString
eval(ρ, Int) = VInt
eval(ρ, Float) = VFloat
eval(ρ, Attrs) = VAttrs
eval(ρ, Path) = VPath
eval(ρ, Derivation) = VDerivation
eval(ρ, Function) = VFunction
eval(ρ, Any) = VAny
eval(ρ, StringLit(s)) = VStringLit(s)
eval(ρ, IntLit(n)) = VIntLit(n)
eval(ρ, FloatLit(f)) = VFloatLit(f)
eval(ρ, AttrsLit) = VAttrsLit
eval(ρ, PathLit) = VPathLit
eval(ρ, DerivationLit) = VDerivationLit
eval(ρ, FnLit) = VFnLit
eval(ρ, AnyLit) = VAnyLit
Most primitives have no eliminators. They exist to integrate
Nix's native types into the kernel's type system as opaque,
axiomatized constants. The exception is String, which has
StrEq (§4.11).
4.11 String equality (StrEq)
eval(ρ, StrEq(lhs, rhs)) = vStrEq(eval(ρ, lhs), eval(ρ, rhs))
where:
-- trueV / falseV are the plus-encoded derived booleans:
-- trueV = VDescCon boolDescV VTt (VBootInl eqTtV eqTtV VBootRefl)
-- falseV = VDescCon boolDescV VTt (VBootInr eqTtV eqTtV VBootRefl)
-- where boolDescV is the generated encoded BoolDT description.
vStrEq(VStringLit(s₁), VStringLit(s₂)) = if s₁ == s₂ then trueV else falseV
vStrEq(VNe(l, sp), rhs) = VNe(l, sp ++ [EStrEq(rhs)])
vStrEq(lhs, VNe(l, sp)) = VNe(l, sp ++ [EStrEq(lhs)])
vStrEq(_, _) = THROW "kernel bug: vStrEq on non-string"
StrEq is a binary predicate on strings. Both arguments must be of
type String. The result type is the derived H.bool —
μ ⊤ (plus (retI tt) (retI tt)) tt — which is the kernel
representation of booleans after their retirement as primitives.
Unlike other eliminators, StrEq has no motive: it always returns
H.bool, not a dependent type.
When both arguments are concrete string literals, vStrEq reduces
to the plus-encoded true_ or false_ value by Nix-level string
comparison. When either argument is neutral, the neutral's spine is
extended with EStrEq carrying the other argument. This is sound
because StrEq is symmetric: StrEq(a, b) ≡ StrEq(b, a) for all
a, b : String.
5. Quotation Rules
quote(d, v) converts a value back to a term, converting levels to
indices. d is the current binding depth.
quote(d, VPi(n, A, cl)) = Pi(n, quote(d, A), quote(d+1, instantiate(cl, fresh(d))))
quote(d, VLam(n, A, cl)) = Lam(n, quote(d, A), quote(d+1, instantiate(cl, fresh(d))))
quote(d, VSigma(n, A, cl)) = Sigma(n, quote(d, A), quote(d+1, instantiate(cl, fresh(d))))
quote(d, VPair(a, b)) = Pair(quote(d, a), quote(d, b), _)
quote(d, VUnit) = Unit
quote(d, VTt) = Tt
quote(d, VEmpty) = Empty
quote(d, VBootSum(A, B)) = BootSum(quote(d, A), quote(d, B))
quote(d, VBootInl(A, B, v)) = BootInl(quote(d, A), quote(d, B), quote(d, v))
quote(d, VBootInr(A, B, v)) = BootInr(quote(d, A), quote(d, B), quote(d, v))
quote(d, VBootEq(A, a, b)) = BootEq(quote(d, A), quote(d, a), quote(d, b))
quote(d, VBootRefl) = BootRefl
quote(d, VU(i)) = U(i)
quote(d, VString) = String
quote(d, VInt) = Int
quote(d, VFloat) = Float
quote(d, VAttrs) = Attrs
quote(d, VPath) = Path
quote(d, VDerivation) = Derivation
quote(d, VFunction) = Function
quote(d, VAny) = Any
quote(d, VStringLit(s)) = StringLit(s)
quote(d, VIntLit(n)) = IntLit(n)
quote(d, VFloatLit(f)) = FloatLit(f)
quote(d, VAttrsLit) = AttrsLit
quote(d, VPathLit) = PathLit
quote(d, VDerivationLit) = DerivationLit
quote(d, VFnLit) = FnLit
quote(d, VAnyLit) = AnyLit
quote(d, VNe(l, sp)) = quoteSp(d, Var(d - l - 1), sp)
quoteSp(d, head, []) = head
quoteSp(d, head, [EApp(v) | rest]) = quoteSp(d, App(head, quote(d, v)), rest)
quoteSp(d, head, [EFst | rest]) = quoteSp(d, Fst(head), rest)
quoteSp(d, head, [ESnd | rest]) = quoteSp(d, Snd(head), rest)
quoteSp(d, head, [EBootSumElim(A,B,P,l,r) | rest]) =
quoteSp(d, BootSumElim(quote(d,A), quote(d,B), quote(d,P), quote(d,l), quote(d,r), head), rest)
quoteSp(d, head, [EBootJ(A,a,P,pr,b) | rest]) =
quoteSp(d, BootJ(quote(d,A), quote(d,a), quote(d,P), quote(d,pr), quote(d,b), head), rest)
quoteSp(d, head, [EStrEq(arg) | rest]) =
quoteSp(d, StrEq(head, quote(d, arg)), rest)
quoteSp(d, head, [EAbsurd(P) | rest]) =
quoteSp(d, Absurd(quote(d, P), head), rest)
fresh(d) = VNe(d, [])
6. Conversion Rules
conv(d, v₁, v₂) checks definitional equality of two values at
binding depth d. Returns boolean. No type information is used —
conversion is purely structural on normalized values.
6.1 Structural rules
conv(d, VU(i), VU(j)) = (i == j)
conv(d, VUnit, VUnit) = true
conv(d, VTt, VTt) = true
conv(d, VEmpty, VEmpty) = true
conv(d, VBootRefl, VBootRefl) = true
conv(d, VString, VString) = true
conv(d, VInt, VInt) = true
conv(d, VFloat, VFloat) = true
conv(d, VAttrs, VAttrs) = true
conv(d, VPath, VPath) = true
conv(d, VDerivation, VDerivation) = true
conv(d, VFunction, VFunction) = true
conv(d, VAny, VAny) = true
conv(d, VStringLit(s₁), VStringLit(s₂)) = (s₁ == s₂)
conv(d, VIntLit(n₁), VIntLit(n₂)) = (n₁ == n₂)
conv(d, VFloatLit(f₁), VFloatLit(f₂)) = (f₁ == f₂)
conv(d, VAttrsLit, VAttrsLit) = true
conv(d, VPathLit, VPathLit) = true
conv(d, VDerivationLit, VDerivationLit) = true
conv(d, VFnLit, VFnLit) = true
conv(d, VAnyLit, VAnyLit) = true
6.2 Binding forms
To compare under binders, generate a fresh variable and instantiate:
conv(d, VPi(_, A₁, cl₁), VPi(_, A₂, cl₂)) =
conv(d, A₁, A₂) ∧ conv(d+1, instantiate(cl₁, fresh(d)), instantiate(cl₂, fresh(d)))
conv(d, VLam(_, _, cl₁), VLam(_, _, cl₂)) =
conv(d+1, instantiate(cl₁, fresh(d)), instantiate(cl₂, fresh(d)))
conv(d, VLam(_, _, cl), v) = -- Π-η
conv(d+1, instantiate(cl, fresh(d)), vApp(v, fresh(d)))
-- only fires when v is not a VLam
conv(d, v, VLam(_, _, cl)) = -- Π-η
conv(d+1, vApp(v, fresh(d)), instantiate(cl, fresh(d)))
-- only fires when v is not a VLam
conv(d, VSigma(_, A₁, cl₁), VSigma(_, A₂, cl₂)) =
conv(d, A₁, A₂) ∧ conv(d+1, instantiate(cl₁, fresh(d)), instantiate(cl₂, fresh(d)))
The two Π-η rules fire when exactly one side is a VLam; both sides being VLam falls through to the symmetric VLam/VLam rule above. Sound because conv is always called on values sharing a type — if one side is VLam, that type is VPi, and the other side's only inhabitants up to definitional equality are its η-expansions. Termination: each rule strictly decreases VLam-depth on the side it fires on, so no nested firing can loop.
6.3 Compound values
conv(d, VPair(a₁, b₁), VPair(a₂, b₂)) =
conv(d, a₁, a₂) ∧ conv(d, b₁, b₂)
conv(d, VPair(a, b), VNe(l, sp)) =
conv(d, a, vFst(VNe(l, sp))) ∧ conv(d, b, vSnd(VNe(l, sp))) -- Σ-η
conv(d, VNe(l, sp), VPair(a, b)) =
conv(d, vFst(VNe(l, sp)), a) ∧ conv(d, vSnd(VNe(l, sp)), b) -- Σ-η
conv(d, VTt, VNe(_, _)) = true -- ⊤-η
conv(d, VNe(_, _), VTt) = true -- ⊤-η
conv(d, VBootSum(A₁, B₁), VBootSum(A₂, B₂)) =
conv(d, A₁, A₂) ∧ conv(d, B₁, B₂)
conv(d, VBootInl(A₁, B₁, v₁), VBootInl(A₂, B₂, v₂)) =
conv(d, A₁, A₂) ∧ conv(d, B₁, B₂) ∧ conv(d, v₁, v₂)
conv(d, VBootInr(A₁, B₁, v₁), VBootInr(A₂, B₂, v₂)) =
conv(d, A₁, A₂) ∧ conv(d, B₁, B₂) ∧ conv(d, v₁, v₂)
conv(d, VBootEq(A₁, a₁, b₁), VBootEq(A₂, a₂, b₂)) =
conv(d, A₁, A₂) ∧ conv(d, a₁, a₂) ∧ conv(d, b₁, b₂)
6.4 Neutrals
conv(d, VNe(l₁, sp₁), VNe(l₂, sp₂)) =
(l₁ == l₂) ∧ convSp(d, sp₁, sp₂)
convSp(d, [], []) = true
convSp(d, [e₁|r₁], [e₂|r₂]) = convElim(d, e₁, e₂) ∧ convSp(d, r₁, r₂)
convSp(d, _, _) = false -- different lengths
convElim(d, EApp(v₁), EApp(v₂)) = conv(d, v₁, v₂)
convElim(d, EFst, EFst) = true
convElim(d, ESnd, ESnd) = true
convElim(d, EBootSumElim(A₁,B₁,P₁,l₁,r₁), EBootSumElim(A₂,B₂,P₂,l₂,r₂)) =
conv(d, A₁, A₂) ∧ conv(d, B₁, B₂) ∧ conv(d, P₁, P₂) ∧ conv(d, l₁, l₂) ∧ conv(d, r₁, r₂)
convElim(d, EBootJ(A₁,a₁,P₁,pr₁,b₁), EBootJ(A₂,a₂,P₂,pr₂,b₂)) =
conv(d, A₁, A₂) ∧ conv(d, a₁, a₂) ∧ conv(d, P₁, P₂) ∧ conv(d, pr₁, pr₂) ∧ conv(d, b₁, b₂)
convElim(d, EStrEq(arg₁), EStrEq(arg₂)) = conv(d, arg₁, arg₂)
convElim(d, EAbsurd(P₁), EAbsurd(P₂)) = conv(d, P₁, P₂)
convElim(_, _, _) = false
6.5 Catch-all
conv(d, _, _) = false
Any pair of values not matching the above rules is not definitionally
equal. Π-eta, Σ-eta, and ⊤-eta are applied (see §6.2 for Π-η, §6.3
for Σ-η and ⊤-η): f converts against λx. f x under a fresh binder;
a pair ⟨a, b⟩ converts against a neutral x : Σ by projecting both
sides; and any neutral of type ⊤ converts against tt. All three
η-rules are sound in the type-free conv because conv is always called
on two values sharing a type — the side carrying the canonical form
(VLam, VPair, VTt) witnesses the shared type's shape (VPi, VSigma, ⊤),
and the other side's η-expansion is its only inhabitant up to
definitional equality.
Π-η rationale. Π-η matches the standard semantic models of MLTT
(PER, presheaf, simplicial sets) and is the η-rule consistent with
funext: f ≡ λx. f x definitionally. Without it, definitional equality
on Pi-typed values diverges from the equality the surface language
must reason about — every elaborator that produces a function value
would have to maintain its own η-normal form before submitting to
conv. With it, conv handles the canonical-vs-neutral case by descending
under one binder and continues structurally; subsequent ⊤-η, Σ-η, and
neutral-vs-neutral rules fire as usual on the body. This composition
in particular is what closes assemblies that pair a surface
descPi k S (λ_:S. tt) D (a 3-arg combinator that fills the kernel's
f : S → ⊤ slot with a constant lambda) against a kernel-reconstructed
descPi k S f D whose f is a case-bound variable.
6.6 Level conversion (convLevel)
Level expressions form a join-semilattice with zero as bottom and
max as join. convLevel(d, k₁, k₂) checks definitional equality of
two Level values modulo the semilattice laws.
Fast path (syntactic equality):
convLevel(d, k, k) = true
When the same Level value reaches both sides of conv (e.g. a description's level is reused unchanged across recursive children), the syntactic-equality check skips the normaliser entirely. Allocations from re-normalising structurally-identical levels dominate hot CHECK loops, so this short-circuit is non-optional.
Normalisation. Each Level value is reduced to its canonical form before structural comparison:
max(k, zero) = max(zero, k) = k(zero absorption)max(k, k) = k(idempotence)suc(max(a, b)) = max(suc(a), suc(b))(suc distributes over max)maxoperands are sorted to a canonical order
The canonical form is max(s₁, …, sₙ) where each sᵢ = sucᵐ(zero)
or sᵢ = sucᵐ(VNe(_, _)), sorted lexicographically. After
normalisation the comparison is structural:
convLevel(d, k₁, k₂) = (normLevel(k₁) ≡_struct normLevel(k₂))
Used by description and universe CHECK rules (§7.6) to verify that two level expressions denote the same level.
7. Typing Rules (Bidirectional)
7.1 Contexts
Ctx ::= {
env : Env, -- values for evaluation
types : [Val], -- types of bound variables (indexed by de Bruijn)
depth : ℕ -- current binding depth
}
emptyCtx = { env = [], types = [], depth = 0 }
extend(Γ, n, A) = {
env = [fresh(Γ.depth)] ++ Γ.env,
types = [A] ++ Γ.types,
depth = Γ.depth + 1
}
lookupType(Γ, i) = Γ.types[i]
-- THROW if i >= length(Γ.types)
7.2 Notation
Γ ⊢ t ⇐ A ↝ t' checking mode: check(Γ, t, A) = t'
Γ ⊢ t ⇒ A ↝ t' synthesis mode: infer(Γ, t) = (t', A)
Γ ⊢ T type ↝ T' type formation: checkType(Γ, T) = T'
Γ ⊢ T type@i ↝ T' type + level: checkTypeLevel(Γ, T) = (T', i)
The output t' is the elaborated core term (fully annotated).
7.3 Synthesis rules (infer)
Var
lookupType(Γ, i) = A
──────────────────────
Γ ⊢ Var(i) ⇒ A ↝ Var(i)
Ann (annotation)
Γ ⊢ A type ↝ A'
 = eval(Γ.env, A')
Γ ⊢ t ⇐ Â ↝ t'
──────────────────────
Γ ⊢ Ann(t, A) ⇒ Â ↝ Ann(t', A')
App (application)
Γ ⊢ f ⇒ fTy ↝ f'
whnf(fTy) = VPi(n, A, cl)
Γ ⊢ u ⇐ A ↝ u'
B = instantiate(cl, eval(Γ.env, u'))
──────────────────────
Γ ⊢ App(f, u) ⇒ B ↝ App(f', u')
CRITICAL: whnf(fTy) must normalize fTy to weak head normal
form before pattern matching. If fTy is a let-unfolding or a
neutral that reduces further, the match will fail spuriously.
In this kernel, eval already produces WHNF, so whnf(v) = v for
all values. But this invariant must be maintained if the value
representation changes.
Fst (first projection)
Γ ⊢ t ⇒ tTy ↝ t'
whnf(tTy) = VSigma(n, A, cl)
──────────────────────
Γ ⊢ Fst(t) ⇒ A ↝ Fst(t')
Snd (second projection)
Γ ⊢ t ⇒ tTy ↝ t'
whnf(tTy) = VSigma(n, A, cl)
B = instantiate(cl, vFst(eval(Γ.env, t')))
──────────────────────
Γ ⊢ Snd(t) ⇒ B ↝ Snd(t')
Eliminator motive checking (checkMotive).
All eliminators require a motive P : domTy → U(k) for some k.
The implementation provides a shared checkMotive helper that
handles two forms:
- Lambda motives (
P = λx. body): extend the context withx : domTyand verifybodyis a type viacheckType. - Non-lambda motives: infer the type and verify it has shape
VPi(_, domTy, _ → VU(k))for somek.
The k is not fixed — motives may target any universe level,
enabling large elimination (eliminators whose return type is a
type, not a value). Generated datatype eliminators use this same
motive checker through DescInd.
Generated eliminators. Public eliminators for natural numbers,
lists, sums, equality, vectors, finite sets, W-types, and user-defined
datatypes are generated as applications of DescInd to their datatype
description. The checker validates the generated motive and branch
terms against the generic indexed-description eliminator.
Public identity elimination. H.j A a P pr b eq is a HOAS adapter
that preserves the usual J-shaped arguments, but elaborates to
EqDT.elim. The adapter checks pr against P a (EqDT.refl A a) and
checks eq against EqDT.T A a b before emitting the generated
eliminator spine.
Bootstrap identity elimination. Internal description machinery may
still use BootJ:
Γ ⊢ A type ↝ A' Â = eval(Γ.env, A')
Γ ⊢ a ⇐ Â ↝ a' â = eval(Γ.env, a')
Γ ⊢ P ⇐ <Π(y : A). Π(e : BootEq A a y). U(k)> ↝ P'
P̂ = eval(Γ.env, P')
Γ ⊢ pr ⇐ vApp(vApp(P̂, â), VBootRefl) ↝ pr'
Γ ⊢ b ⇐ Â ↝ b' b̂ = eval(Γ.env, b')
Γ ⊢ eq ⇐ VBootEq(Â, â, b̂) ↝ eq'
──────────────────────
Γ ⊢ BootJ(A, a, P, pr, b, eq) ⇒ vApp(vApp(P̂, b̂), eval(Γ.env, eq'))
↝ BootJ(A', a', P', pr', b', eq')
Bootstrap J motive verification. For non-lambda motives, the implementation structurally verifies all three components:
- Outer Pi domain matches
A(conversion check) - Inner Pi domain matches
BootEq(A, a, y)(conversion check) - Innermost codomain is
VU(k)for somek
For lambda motives (P = λy. body), the body is checked via
checkMotive against BootEq(A, a, y), which performs the same
verification on the inner structure. This catches motive errors
at the motive itself rather than deferring to the base case.
Axiomatized primitive type formers (synthesis)
Primitive type formers are synthesized directly — they infer as
inhabitants of U(0):
──────────────────────
Γ ⊢ String ⇒ VU(0) ↝ String
──────────────────────
Γ ⊢ Int ⇒ VU(0) ↝ Int
(Similarly for Float, Attrs, Path, Derivation, Function, Any — all at level 0.)
Primitive literals (synthesis)
Literals synthesize their corresponding type:
──────────────────────
Γ ⊢ StringLit(s) ⇒ VString ↝ StringLit(s)
──────────────────────
Γ ⊢ IntLit(n) ⇒ VInt ↝ IntLit(n)
──────────────────────
Γ ⊢ FloatLit(f) ⇒ VFloat ↝ FloatLit(f)
(Similarly for AttrsLit → VAttrs, PathLit → VPath, DerivationLit → VDerivation, FnLit → VFunction, AnyLit → VAny.)
StrEq (string equality)
boolDescV = eval [] (elab BoolDT.D)
boolV = VMu VUnit boolDescV VTt
Γ ⊢ lhs ⇐ VString ↝ lhs'
Γ ⊢ rhs ⇐ VString ↝ rhs'
──────────────────────
Γ ⊢ StrEq(lhs, rhs) ⇒ boolV ↝ StrEq(lhs', rhs')
Both arguments are checked against VString. The result type is
the derived H.bool — μ ⊤ (plus (retI tt) (retI tt)) tt —
written boolV above. StrEq is not a dependent eliminator: it has
no motive parameter.
7.4 Checking rules (check)
Lam (lambda introduction)
whnf(A) = VPi(n, dom, cl)
Γ' = extend(Γ, n, dom)
cod = instantiate(cl, fresh(Γ.depth))
Γ' ⊢ t ⇐ cod ↝ t'
──────────────────────
Γ ⊢ Lam(n, _, t) ⇐ A ↝ Lam(n, quote(Γ.depth, dom), t')
Pair (pair introduction)
whnf(T) = VSigma(n, A, cl)
Γ ⊢ a ⇐ A ↝ a'
B = instantiate(cl, eval(Γ.env, a'))
Γ ⊢ b ⇐ B ↝ b'
──────────────────────
Γ ⊢ Pair(a, b, _) ⇐ T ↝ Pair(a', b', quote(Γ.depth, T))
Generated datatype constructors. Public constructors such as
H.zero, H.succ, H.nil, H.cons, H.inl, H.inr, and
user-defined datatype constructors check as DescCon introductions
against their generated VMu type. Their payloads are checked against
the interpretation of the constructor's description. Deep generated
natural/list constructor chains are handled by flat constructor
elaboration and desc-con trampolines.
Tt
whnf(A) = VUnit
──────────────────────
Γ ⊢ Tt ⇐ A ↝ Tt
BootRefl
whnf(T) = VBootEq(A, a, b)
conv(Γ.depth, a, b) = true
──────────────────────
Γ ⊢ BootRefl ⇐ T ↝ BootRefl
If conv(Γ.depth, a, b) = false, this is a type error: the
two sides of the equation are not definitionally equal, and
BootRefl cannot prove the equation. Public H.refl is resolved by
HOAS check mode against generated EqDT.T A a a, elaborating to
EqDT.refl A a; unrestricted inference for bare public refl is
rejected.
Primitive literals (checked against their corresponding types)
whnf(A) = VString
──────────────────────
Γ ⊢ StringLit(s) ⇐ A ↝ StringLit(s)
whnf(A) = VInt
──────────────────────
Γ ⊢ IntLit(n) ⇐ A ↝ IntLit(n)
whnf(A) = VFloat
──────────────────────
Γ ⊢ FloatLit(f) ⇐ A ↝ FloatLit(f)
whnf(A) = VAttrs
──────────────────────
Γ ⊢ AttrsLit ⇐ A ↝ AttrsLit
whnf(A) = VPath
──────────────────────
Γ ⊢ PathLit ⇐ A ↝ PathLit
whnf(A) = VDerivation
──────────────────────
Γ ⊢ DerivationLit ⇐ A ↝ DerivationLit
whnf(A) = VFunction
──────────────────────
Γ ⊢ FnLit ⇐ A ↝ FnLit
whnf(A) = VAny
──────────────────────
Γ ⊢ AnyLit ⇐ A ↝ AnyLit
Let
Γ ⊢ A type ↝ A'
 = eval(Γ.env, A')
Γ ⊢ t ⇐ Â ↝ t'
t̂ = eval(Γ.env, t')
Γ' = { env = [t̂] ++ Γ.env, types = [Â] ++ Γ.types, depth = Γ.depth + 1 }
Γ' ⊢ u ⇐ B ↝ u'
──────────────────────
Γ ⊢ Let(n, A, t, u) ⇐ B ↝ Let(n, A', t', u')
Note: Let in checking mode — the expected type B is for the
body u, not for the definition t.
Sub (mode switch: fall through to synthesis)
Γ ⊢ t ⇒ A ↝ t'
conv(Γ.depth, A, B) = true -- or cumulativity check
──────────────────────
Γ ⊢ t ⇐ B ↝ t'
This is the catch-all. If no other checking rule applies, try synthesis and verify the inferred type matches the expected type.
7.5 Type formation (checkType / checkTypeLevel)
The implementation provides two variants: checkType(Γ, T) returns
only the elaborated term, while checkTypeLevel(Γ, T) returns both
the elaborated term and the universe level. checkType is a thin
wrapper: checkType(Γ, T) = checkTypeLevel(Γ, T).term. Universe
levels are computed structurally during the type formation check
(see §8.2), not by post-hoc inspection of evaluated values.
──────────────────────
Γ ⊢ Unit type ↝ Unit
──────────────────────
Γ ⊢ Empty type ↝ Empty
Γ ⊢ P type ↝ P' level(P') = k
Γ ⊢ x ⇐ Empty ↝ x'
──────────────────────
Γ ⊢ Absurd(P, x) ⇒ P̂ (P̂ = eval(Γ.env, P'))
↝ Absurd(P', x')
──────────────────────
Γ ⊢ String type ↝ String
(Similarly for Int, Float, Attrs, Path, Derivation, Function, Any)
──────────────────────
Γ ⊢ U(i) type ↝ U(i)
Γ ⊢ A type ↝ A'
Γ ⊢ B type ↝ B'
──────────────────────
Γ ⊢ BootSum(A, B) type ↝ BootSum(A', B')
Γ ⊢ A type ↝ A' Â = eval(Γ.env, A')
Γ ⊢ a ⇐ Â ↝ a'
Γ ⊢ b ⇐ Â ↝ b'
──────────────────────
Γ ⊢ BootEq(A, a, b) type ↝ BootEq(A', a', b')
Γ ⊢ A type ↝ A'
 = eval(Γ.env, A')
Γ' = extend(Γ, n, Â)
Γ' ⊢ B type ↝ B'
──────────────────────
Γ ⊢ Pi(n, A, B) type ↝ Pi(n, A', B')
Γ ⊢ A type ↝ A'
 = eval(Γ.env, A')
Γ' = extend(Γ, n, Â)
Γ' ⊢ B type ↝ B'
──────────────────────
Γ ⊢ Sigma(n, A, B) type ↝ Sigma(n, A', B')
-- Fallback: infer and check it's a universe
Γ ⊢ T ⇒ A ↝ T'
whnf(A) = VU(i)
──────────────────────
Γ ⊢ T type ↝ T'
Public generated data types enter this judgment through the fallback:
their expanded Mu(...) terms infer a universe.
7.6 Descriptions: typing rules
Descriptions classify strictly-positive datatype signatures over an
index type I. A description D : Desc^k I quantifies over at most
universe level k: descArg^k S T requires S : U(k), and
descPi^k S f D requires S : U(k) and f : S → I. The
description's level k is recovered from the surrounding μ (via
mu I D i) at elaboration time; CHECK rules thread k through
recursive children without re-synthesising it ("homogeneity by
typing", below).
Notation. Desc^k I is the kernel value VDesc(K, Î) where
K : Level (§8.5). μ I D i is the kernel value VMu(Î, D̂, î),
the i-th type in the family classified by D.
7.6.1 Description eliminators are encoded at the HOAS layer
There is no DescElim core term in the current kernel. The HOAS
surface exposes description elimination by applying the generated
encodeDescElim program to a descDesc I K value. The trusted core
operations involved are DescDescApp, InterpD, AllD,
EverywhereD, and DescInd.
The level agreement once handled by a primitive DescElim rule is now
checked by the ordinary typing rules for Desc I K, interpD, and the
encoded eliminator's Pi type. DescDescApp(I, K) carries a canonical
reference so quotation and conversion can recognize the self-describing
description universe without forcing the recursive encoding.
7.6.2 Homogeneity by typing (desc CHECK rules)
When CHECKing a description against Desc^K I, recursive sub-checks
inherit K directly from the surrounding type rather than
synthesising their own. The principle:
A description's recursive children inhabit the same description type as the parent. Reconstructing
VDesc(K, Î)per recursive call to thread the type — only to have the recursive CHECK pattern-match it back open — is wasted allocation.
The CHECK rules pass the surrounding type directly:
whnf(ty) = VDesc(K, Î)
Γ ⊢ S ⇐ VU(K) ↝ S' Ŝ = eval(Γ.env, S')
Γ ⊢ T ⇐ VPi(_, Ŝ, ([], ty)) ↝ T' -- recursive, same ty
──────────────────────
Γ ⊢ DescArg(K, S, T) ⇐ ty ↝ DescArg(K, S', T')
whnf(ty) = VDesc(K, Î)
Γ ⊢ S ⇐ VU(K) ↝ S' Ŝ = eval(Γ.env, S')
Γ ⊢ f ⇐ VPi(_, Ŝ, ([], Î)) ↝ f'
Γ ⊢ D ⇐ VPi(_, Ŝ, ([], ty)) ↝ D' -- recursive, same ty
──────────────────────
Γ ⊢ DescPi(K, S, f, D) ⇐ ty ↝ DescPi(K, S', f', D')
whnf(ty) = VDesc(K, Î)
Γ ⊢ A ⇐ ty ↝ A' -- recursive, same ty
Γ ⊢ B ⇐ ty ↝ B' -- recursive, same ty
──────────────────────
Γ ⊢ DescPlus(A, B) ⇐ ty ↝ DescPlus(A', B')
whnf(ty) = VDesc(K, Î)
Γ ⊢ j ⇐ Î ↝ j'
Γ ⊢ D ⇐ ty ↝ D' -- recursive, same ty
──────────────────────
Γ ⊢ DescRec(j, D) ⇐ ty ↝ DescRec(j', D')
whnf(ty) = VDesc(K, Î)
Γ ⊢ j ⇐ Î ↝ j'
──────────────────────
Γ ⊢ DescRet(j) ⇐ ty ↝ DescRet(j')
The K and Î are recovered once at the outermost CHECK, then
reused unchanged by every recursive sub-check. The rules' written
shape mirrors the implementation: ty (the surrounding VDesc
value) flows into recursive positions verbatim, and convLevel on
the inner description's level reduces to convLevel(K, K) — the
syntactic-equality fast-path of §6.6 fires.
7.6.3 desc-con CHECK with checkDescAtAnyLevel
A μ I D i introduction descCon D i d checks the description D
against Desc^K I, where K is recovered from the surrounding
μ's classifier. Because μ at indexed positions is checked
against VMu(Î, D̂, î) whose D̂ field carries no externally-visible
level, the CHECK rule infers the level from the type-of-D̂ at
elaboration:
whnf(ty) = VMu(Î, D̂, î)
checkDescAtAnyLevel(Γ, D) = (D', K)
conv(Γ.depth, eval(Γ.env, D'), D̂) = true
Γ ⊢ i ⇐ Î ↝ i'
conv(Γ.depth, eval(Γ.env, i'), î) = true
Γ ⊢ payload ⇐ <interp(D̂, î) at level K> ↝ payload'
──────────────────────
Γ ⊢ DescCon(D, i, payload) ⇐ ty
↝ DescCon(D', i', payload')
checkDescAtAnyLevel(Γ, D) is the bidirectional bridge: it
elaborates D at the most specific admissible level synthesised
from D's structure, returning both the elaborated term and the
inferred K. Equivalent to ∃K. Γ ⊢ D ⇐ Desc^K Î. The bidirectional
discipline at index positions is preserved: canonical intros (tt,
zero, refl, …) at the index slot remain checkable-only.
8. Universe Rules
8.1 Universe formation
U(i) : U(i + 1) for all i ≥ 0
8.2 Type former levels
Universe levels are computed by checkTypeLevel, which returns
{ term; level; } from the typing derivation, not from
post-hoc value inspection. This avoids the problem of unknown
levels for neutral type variables. We write level(A) as shorthand
for checkTypeLevel(Γ, A).level.
checkTypeLevel(Γ, Unit) = { Unit, 0 }
checkTypeLevel(Γ, Empty) = { Empty, 0 }
checkTypeLevel(Γ, String) = { String, 0 }
checkTypeLevel(Γ, Int) = { Int, 0 }
checkTypeLevel(Γ, Float) = { Float, 0 }
checkTypeLevel(Γ, Attrs) = { Attrs, 0 }
checkTypeLevel(Γ, Path) = { Path, 0 }
checkTypeLevel(Γ, Derivation) = { Derivation, 0 }
checkTypeLevel(Γ, Function) = { Function, 0 }
checkTypeLevel(Γ, Any) = { Any, 0 }
checkTypeLevel(Γ, BootSum(A,B))= { BootSum(A',B'), max(level(A), level(B)) }
checkTypeLevel(Γ, Pi(n, A, B)) = { Pi(n,A',B'), max(level(A), level(B)) }
checkTypeLevel(Γ, Sigma(n,A,B))= { Sigma(n,A',B'), max(level(A), level(B)) }
checkTypeLevel(Γ, BootEq(A,a,b)) = { BootEq(A',a',b'), level(A) }
checkTypeLevel(Γ, U(i)) = { U(i), i + 1 }
-- Fallback: infer type, require VU(i), extract i
checkTypeLevel(Γ, T) = { T', i } where Γ ⊢ T ⇒ VU(i)
The fallback handles neutral type expressions (variables,
applications) by inferring their type and requiring it to be a
universe. This correctly propagates levels through type variables:
if B : U(1), then checkTypeLevel on B infers VU(1) and
returns level 1.
8.3 Cumulativity
A type A at level i is also a type at level j for all j > i.
This is implemented by accepting conv(d, VU(i), VU(j)) when i ≤ j
in the Sub rule only (checking mode, when comparing an inferred
universe against an expected universe). The conv function itself
uses strict equality i == j.
The cumulativity check is in check:
-- In the Sub rule:
-- If inferredTy = VU(i) and expectedTy = VU(j) and i ≤ j: accept
-- Otherwise: conv(Γ.depth, inferredTy, expectedTy) must hold
8.4 Universe consistency
The kernel MUST reject U(i) : U(i). This is guaranteed by the
level computation: level(U(i)) = i + 1, so U(i) lives at level
i + 1, not i. Self-containing universes cannot be constructed.
This prevents Girard's paradox (Girard 1972), which requires a type
that contains itself. Hurkens (1995) gives the compact MLTT rendering
of the inconsistency proof. Universe stratification is the standard
fix, and it is why the kernel enforces level(U(i)) = i + 1.
8.5 Level sort
Level is a Tarski-style sort of universe levels, with constructors
zero, suc, and max. It inhabits the lowest universe:
──────────────────────
Γ ⊢ Level type ↝ Level
──────────────────────
Γ ⊢ Level ⇒ VU(0) ↝ Level
──────────────────────
Γ ⊢ LevelZero ⇐ Level ↝ LevelZero
Γ ⊢ k ⇐ Level ↝ k'
──────────────────────
Γ ⊢ LevelSuc(k) ⇐ Level ↝ LevelSuc(k')
Γ ⊢ a ⇐ Level ↝ a'
Γ ⊢ b ⇐ Level ↝ b'
──────────────────────
Γ ⊢ LevelMax(a, b) ⇐ Level ↝ LevelMax(a', b')
Conversion modulo the semilattice laws (idempotence of max, suc
distribution over max, zero absorption) is delegated to convLevel
(§6.6); structural conversion on Level values without normalisation
would be too coarse (e.g. max(zero, k) and k would not compare
equal).
The Level sort enables predicative universe polymorphism: the
description type Desc^k I and universe U(k) quantify over
arbitrary levels via Π(k:Level). …. Rank-1 only — Level itself
has no eliminator, and admitting one would break parametricity over
the semilattice quotient (any eliminator would be observably
sensitive to the canonical form chosen by convLevel).
9. Fuel Mechanism
9.1 Evaluation fuel
Every call to evalF receives a fuel parameter and decrements it
by one before evaluating the term. When fuel reaches 0:
evalF(fuel=0, ρ, t) = THROW "normalization budget exceeded"
The kernel aborts via throw. Layer 0 (TCB) has no access to the
effect system by design, so fuel exhaustion and kernel invariant
violations both manifest as Nix-level throws caught by tryEval.
Callers should treat any throw from the evaluator as "term not
verified" — the distinction between fuel exhaustion and a kernel bug
is in the error message text, not the failure mechanism.
9.2 Default budget
The default fuel budget is 10,000,000 reduction steps. This is
configurable by the caller via evalF. No minimum is enforced —
callers may pass arbitrarily low fuel, which will cause immediate
throw on the first eval step.
9.3 Fuel accounting
Fuel is per-path, not a global counter. Each call to evalF
captures f = fuel - 1 and passes f to all sub-evaluations of
that term. When evaluating App(t, u), both evalF(f, ρ, t) and
evalF(f, ρ, u) receive the same f. This means fuel bounds the
depth of any single evaluation path, not the total work across
all paths.
For a balanced binary tree of N applications, the total work is O(2^depth × fuel), not O(fuel). This is inherent to pure Nix — there is no mutable global counter. The fuel mechanism guarantees termination (every path eventually hits 0) but does not bound total computation time.
All fuel consumption flows through evalF:
- Direct term evaluation (each
evalFcall decrements fuel by 1) - Beta-reduction in
vAppconsumes fuel indirectly viainstantiateF, which callsevalF - Iota-reduction in generated recursive eliminators consumes fuel
through
vDescIndF,vAllDF,vEverywhereDF, andvAppF
BootJ completes in O(1) on BootRefl. BootSumElim is
non-recursive, but branch selection calls vAppF on the selected
branch. Structural operations (building values, pattern matching on
tags) do not consume fuel.
9.4 Fuel threading in generated eliminators
Generated natural/list eliminators route through datatype-specific
wrappers and the generic DescInd evaluator. Deep constructor chains
are not handled by hard-coded nat/list cases: DescCon evaluation
recognizes homogeneous linear recursive payloads from the description
profile and flattens them into builtins.foldl' loops. Each fold step
threads fuel through the accumulator:
foldl'(λ{acc, fuel}. λi.
if fuel ≤ 0 then THROW "normalization budget exceeded"
else { acc = step(fuel, acc, chain[i]); fuel = fuel - 1; })
{acc = base; fuel = fuel}
[1..n]
This ensures that an N-element chain consumes N units of fuel from the fold, plus whatever fuel each step application consumes internally. Without this threading, each step would get the original fuel budget, giving an effective budget of N × fuel.
The worst-case complexity of a threaded fold is O(fuel²): at step
i, the inner vAppF receives fuel - i as its own per-path
budget. Summing over all steps gives Σ(fuel - i) ≈ fuel²/2. To
achieve O(fuel), vAppF would need to return remaining fuel — an
invasive signature change. The quadratic residual is inherent to
per-path fuel semantics and is a strict improvement over the
pre-threading O(N × fuel) with unbounded N.
9.5 Fuel consumption in constructor chains
Generated constructor evaluation flattens chains of n DescCon
layers when the description's linear profile proves the recursion is
structurally homogeneous. The evaluator deducts n fuel units from the
budget before rebuilding the semantic value. Generated natural and
list constructors are instances of this generic path, so the 5000-deep
stress tests are evidence for the description-level trampoline rather
than for datatype-specific hard-coding.
10. Properties the Implementation Must Satisfy
10.1 Soundness (non-negotiable)
If the kernel accepts Γ ⊢ t : A, then t is a valid term of
type A in MLTT with the specified type formers and universe
hierarchy. Formally:
If check(Γ, t, A) succeeds, then Γ ⊢ t : A is derivable
in the declarative typing rules of MLTT.
Equivalently: the kernel never accepts an ill-typed term.
10.2 Determinism
For any input (Γ, t, A), the kernel produces the same result
on every invocation. There is no randomness, no system-dependent
behavior, no sensitivity to evaluation order (beyond fuel
exhaustion, which always rejects).
10.3 Termination
For any input (Γ, t, A), the kernel terminates. It either:
- Accepts (returns the elaborated term)
- Rejects with a type error (via effect)
- Rejects with fuel exhaustion
- Crashes with a kernel bug diagnostic (throw)
It never loops. The fuel mechanism guarantees this.
10.4 Evaluation roundtrip
For any well-typed term t and environment ρ consistent with
the context:
quote(d, eval(ρ, quote(d, eval(ρ, t)))) = quote(d, eval(ρ, t))
Evaluation followed by quotation is idempotent. The result is a normal form.
10.5 Conversion reflexivity
For any value v:
conv(d, v, v) = true
10.6 Conversion symmetry
For any values v₁, v₂:
conv(d, v₁, v₂) = conv(d, v₂, v₁)
10.7 Conversion transitivity
For any values v₁, v₂, v₃:
conv(d, v₁, v₂) ∧ conv(d, v₂, v₃) ⟹ conv(d, v₁, v₃)
10.8 Type preservation under evaluation
If Γ ⊢ t : A and eval(Γ.env, t) = v, then v represents a
value of type A. This is not directly testable (values don't
carry types) but is ensured by the correctness of the evaluation
rules.
10.9 Strong normalization (for well-typed terms)
For any well-typed term t, eval terminates without exhausting
fuel for a sufficiently large fuel budget. The fuel mechanism is
a practical safeguard, not a theoretical necessity for well-typed
terms.
11. Derived Test Cases
Every rule in this spec generates at least one positive test (the rule applies and succeeds) and one negative test (the rule's premises are violated and the kernel rejects).
11.1 Required positive tests (kernel must ACCEPT)
-- Public generated identity
⊢ H.refl : H.eq H.nat H.zero H.zero
-- Function type
⊢ Lam(x, H.nat, Var(0)) : Pi(x, H.nat, H.nat)
-- Application
f : Pi(x, H.nat, H.nat) ⊢ App(f, H.zero) : H.nat
-- Dependent function
⊢ Lam(A, U(0), Lam(x, Var(0), Var(0))) : Pi(A, U(0), Pi(x, A, A))
-- Sigma pair
⊢ Pair(H.zero, Tt, Sigma(x, H.nat, Unit)) : Sigma(x, H.nat, Unit)
-- Generated natural induction: 0 + 0 = 0
⊢ H.refl : H.eq H.nat (H.ind ... H.zero) H.zero
-- List
⊢ H.cons H.nat H.zero (H.nil H.nat) : H.listOf H.nat
-- Sum injection
⊢ H.inl H.nat Unit H.zero : H.sum H.nat Unit
-- Universe hierarchy
⊢ U(0) : U(1)
⊢ U(1) : U(2)
⊢ H.nat : U(0)
⊢ Pi(x, H.nat, H.nat) : U(0)
-- Let binding
⊢ Let(x, H.nat, H.zero, Var(0)) : H.nat
-- Cumulativity: H.nat : U(0) should also be accepted at U(1)
-- StrEq: type inference returns the derived H.bool
-- (= μ ⊤ (plus (retI tt) (retI tt)) tt; see §4.11)
⊢ StrEq(StringLit("a"), StringLit("b")) : H.bool
-- StrEq reduction: equal strings reduce to the derived true_ value;
-- unequal strings reduce to false_. Both witnessed via H.refl over the
-- derived-bool form. Expressing this rule at the Tm level requires
-- the plus/μ machinery; see the examples/verified-functions.nix
-- fixture `recordStrEqMatch` for an executable test.
11.2 Required negative tests (kernel must REJECT)
-- Type mismatch
⊢ H.zero : Unit REJECT
-- Universe violation
⊢ U(0) : U(0) REJECT
-- H.refl on unequal terms
⊢ H.refl : H.eq H.nat H.zero (H.succ H.zero) REJECT
-- Application of non-function
⊢ App(H.zero, H.zero) REJECT
-- Projection of non-pair
⊢ Fst(H.zero) REJECT
-- Wrong eliminator scrutinee
⊢ H.ind ... Tt REJECT (Tt : Unit, not H.nat)
-- Unbound variable
⊢ Var(0) (in empty context) REJECT
-- StrEq on non-string
⊢ StrEq(H.zero, StringLit("foo")) REJECT (lhs is H.nat, expected String)
-- Ill-typed pair under expected Sigma
⊢ Pair(H.zero, H.zero) ⇐ Sigma(x, H.nat, Unit) REJECT
11.3 Required stress tests
-- Large H.nat: H.succ^5000(H.zero) : H.nat ACCEPT (trampoline)
-- Large H.listOf: H.cons^5000 : H.listOf H.nat ACCEPT (trampoline)
-- H.ind on H.succ^5000(H.zero) ACCEPT (trampoline)
-- H.listElim on H.cons^5000 ACCEPT (trampoline)
-- Succ elaboration: elab-succ-5000 ACCEPT (trampoline)
-- Cons elaboration: elab-cons-5000 ACCEPT (trampoline)
-- Deeply nested Pi: Pi(x₁, ..., Pi(xₙ, H.nat, H.nat)...) for n=500 ACCEPT
-- Fuel exhaustion: artificially low fuel on complex term REJECT (fuel)
-- Fuel threading: generated natural fold decrements fuel per step ACCEPT
-- Fuel threading: generated list fold decrements fuel per step ACCEPT
11.4 Required roundtrip tests
For each value form, verify:
quote(d, eval(ρ, t)) = normal_form(t)
where normal_form(t) is the expected normal form.
12. Notation Index
| Symbol | Meaning |
|---|---|
| Γ | Typing context |
| ρ | Value environment |
| d | Binding depth (for levels ↔ indices) |
| ⊢ | Typing judgment |
| ⇐ | Checking mode |
| ⇒ | Synthesis mode |
| ↝ | Elaborates to |
| ≡ | Definitional equality |
| Π | Dependent function type |
| Σ | Dependent pair type |
| ℕ | Natural numbers |
| 𝔹 |
Booleans (derived: μ ⊤ (plus (retI tt) (retI tt)) tt) |
| ⊤ | Unit type |
| ⊥ | Empty type |
| U(i) | Universe at level i |
| Id_A(a,b) | Identity type |
| TCB | Trusted computing base |
| WHNF | Weak head normal form |
| NbE | Normalization by evaluation |
| THROW | Kernel invariant violation (crash) |
| REJECT | Term rejected (via effect or fuel) |
13. Known Limitations
The following are documented implementation choices or limitations, not bugs. They are recorded here so auditors do not rediscover them.
13.1 Trusted annotation sidecars are semantically erased
Ann terms may carry implementation sidecars such as trusted,
_descRef, _label, and _conLabel. Evaluation may propagate those
sidecars to values for performance, source maps, or generated datatype
metadata, but conversion ignores them. They must never affect
definitional equality.
13.2 Lambda domain annotations discarded in checking mode
When checking Lam(n, A, t) against VPi(n, dom, cl), the lambda's
domain annotation A is discarded and replaced by dom from the
Pi type. This is standard bidirectional type checking (Dunfield &
Krishnaswami 2021, §4): in checking mode, the expected type provides
the domain, not the term. The elaborated output uses quote(d, dom),
making the original annotation unrecoverable.
13.3 Term constructors do not validate argument types
Term constructors (mkVar, mkApp, etc.) accept arbitrary Nix
values without type validation. mkVar "hello" produces
{ tag = "var"; idx = "hello"; }, which crashes at eval time.
The trust boundary is the HOAS layer (src/tc/hoas/), which is the
public API — direct term construction is internal to the kernel.
13.4 tryEval only catches throw and assert false
builtins.tryEval in the elaborator's isConstantFamily sentinel
detection only catches explicit throw and assert false. Nix
coercion errors (e.g., "cannot convert a function to JSON"),
missing attribute access, and type comparison errors are uncatchable.
The elaborator uses builtins.typeOf in error paths to avoid
triggering coercion errors.
13.5 HOAS sentinel comparison
The isConstantFamily sentinel test in the elaborator applies two
distinct sentinel values and compares the results to detect whether
a binding body is dependent. Both Pi and Sigma paths compare
elaborated kernel terms (H.elab r1.value == H.elab r2.value)
rather than raw HOAS trees. This avoids false negatives from Nix's
function identity comparison (== on lambdas). However, if H.elab
itself produces structurally different terms for semantically
equivalent types (e.g., through different elaboration paths), false
negatives remain possible. This is a safe failure mode — the kernel
still type-checks correctly, but elaboration may require explicit
_kernel annotations unnecessarily.
13.6 StrEq neutral canonicalization
When one argument to vStrEq is neutral and the other is a literal,
the neutral's spine is extended with EStrEq(literal). When both
arguments are neutral, the left neutral's spine is extended with
EStrEq(right). This means StrEq(x, y) and StrEq(y, x) (where
both are neutral) produce different normal forms: VNe(x, [EStrEq(y)])
vs VNe(y, [EStrEq(x)]). Therefore conv will report them as
not definitionally equal, even though StrEq is semantically
symmetric. This is a safe conservatism: the kernel may reject some
provable equalities but never accepts a false one.
13.7 Extract uses type value threading (not sentinels)
The extract function threads kernel type values (tyVal) through
recursive extraction, rather than using sentinel-based non-dependence
tests. For Pi extraction, the codomain type is computed per-invocation
via instantiate(tyVal.closure, kernelArg), supporting both dependent
and non-dependent function extraction. For Sigma extraction (records),
the second component's type is computed via
instantiate(tyVal.closure, val.fst). A reifyType : Val → HoasTree
fallback converts kernel type values back to HOAS when the HOAS body
cannot be applied (e.g., when the body accesses record fields from a
neutral). reifyType loses sugar (VSigma → H.sigma, not
H.record) so the HOAS body is preferred when available.
13.8 Spine comparison complexity
convSp uses builtins.elemAt in a fold to compare neutral spines.
In Nix, builtins.elemAt on lists is O(1) (Nix lists are internally
vectors/arrays), so the actual complexity is O(n), not O(n²). This
was incorrectly flagged in an earlier audit.
References
- Coquand, T. et al. (2009). A simple type-theoretic language: Mini-TT.
- Dunfield, J. & Krishnaswami, N. (2021). Bidirectional Typing. ACM Computing Surveys.
- Kovács, A. (2022). Generalized Universe Hierarchies. CSL 2022.
- Abel, A. & Chapman, J. (2014). Normalization by Evaluation in the Delay Monad.
- Girard, J.-Y. (1972). Interprétation fonctionnelle et élimination des coupures de l'arithmétique d'ordre supérieur. Thèse d'État, Université Paris 7.
- Hurkens, A. J. C. (1995). A Simplification of Girard's Paradox. TLCA 1995.
- de Bruijn, N. (1972). Lambda Calculus Notation with Nameless Dummies.
- Martin-Löf, P. (1984). Intuitionistic Type Theory. Bibliopolis.
- Felicissimo, T. (2023). Generic Bidirectional Typing for Dependent Type Theories.