elab design
The elab package is the kernel of stella. It implements a dependent type theory in the style of Martin-Löf with a unit type, , , identity and W types and a cumulative hierarchy of universes, and it decides typing with a bidirectional checker whose definitional equality is computed by normalisation by evaluation (NbE). This page states the rules the code implements, derives the properties that make them work, and records where the implementation is incomplete.
Design goal
- A small, readable kernel whose structure mirrors the theory: one function per judgement, one match arm per rule.
- Decidable checking with few annotations: the user annotates only where the checker cannot infer.
- Equality of types by computation: two types are compared by evaluating them and comparing normal forms, not by rewriting syntax.
- An implementation close to the references the project follows: Löh, McBride and Swierstra’s tutorial implementation and Norell’s thesis on Agda.11 A. Löh, C. McBride, W. Swierstra, “A tutorial implementation of a dependently typed lambda calculus”, Fundamenta Informaticae 102 (2010). U. Norell, Towards a practical programming language based on dependent type theory, PhD thesis, Chalmers (2007).
Mathematical background
Syntax
Terms are split by the judgement that handles them. Writing for inferable terms (TermInf) and for checkable terms (TermChk):
Bound variables are de Bruijn indices (Bound(i)): refers to the nearest enclosing binder. The binders are and the second argument of , and . Because a bound variable has no name, two terms are -equivalent exactly when they are equal as trees, so the derived Eq of TermInf and TermChk is -equivalence.
Values and neutral terms
Evaluation maps terms into a semantic domain (Value):
where are MoonBit functions. A binder body becomes a function on values: is the type . The neutral terms (Neutral) are eliminations stuck on a free variable .
Every value is in weak head normal form: no elimination is applied to an introduction form, because the evaluator reduces such redexes as soon as it builds them.
Evaluation
Evaluation (eval_inf, eval_chk) takes an environment whose -th entry is the value of :
and similarly for the other constructors. The semantic eliminations (val_app, val_fst, val_snd, val_j_elim, val_w_rec) carry the computation rules:
and on a neutral argument they extend the spine, for example . Annotations are erased.
Read-back and normal forms
Read-back (quote, neutral_quote) turns a value under binders into a term. A function is read back by applying it to a fresh variable:
and a fresh variable is turned back into an index:
Why . Read-back numbers binders by level, from the outside: the binder opened at depth introduces . At depth , the binders opened after it have levels , so binders lie between the occurrence and its binder, which is its de Bruijn index. The variable is fresh because at depth only are in scope. Levels make freshness trivial (no renaming, no shifting), and indices make the output canonical.
The normal form of a closed term is .
Why evaluation respects
The central lemma of NbE is that -equal terms have the same value. For a redex,
where the last step is the substitution lemma, proved by induction on : substituting for and evaluating in gives the same value as evaluating in extended with the value of . The same computation for the other redexes uses , and above. Since the value of a term depends only on its -class, so does its normal form: . Conversely, is reached from by -steps, so equal normal forms imply -equality. Together these make “compare normal forms” a decision procedure for -equality on terms whose evaluation terminates.22 U. Berger and H. Schwichtenberg, “An inverse of the evaluation functional for typed λ-calculus”, LICS 1991, introduced NbE. A. Abel, Normalization by Evaluation: Dependent Types and Impredicativity, habilitation, LMU Munich (2013), proves soundness and completeness of NbE for Martin-Löf type theory with . These are results about the theory; for this implementation they are tested, not proved.
The bidirectional judgements
The checker has two judgements, each a function:
- (inference,
type_inf): has type , which the checker computes; - (checking,
type_chk): has the given type .
The context maps names to types (values), is the environment of the current position, and counts the binders entered. Under a binder the checker extends all three with a fresh variable : it writes . Thus always maps to the variable , and gives its type. Below, abbreviates .
Variables, constants and annotations.
Universes and type formers. Here and below, a premise requires to be an inferable term whose inferred type is a universe; there is no subsumption in these premises.
The rules and are the same with and in place of . The identity type lives in the universe of its carrier:
Introductions are checked. The expected type supplies what the term omits, such as the domain of a :
Eliminations are inferred. The type of the eliminated term is inferred and then taken apart:
Path induction, with the motive inferred and , , , :
The universe level of the motive is inferred rather than fixed, which is what cumulative universes need. The two domains are still checked: is only ever applied to a point of and to a path out of , so its domains must accept those arguments, and is contravariant in its domain. Checking only the shape would let be applied to arguments of the wrong type during checking.
W recursion, where is an inferable function , and :
Changing direction. An inferable term is accepted in checking mode when its type is a subtype of the expected one:
The opposite direction is : a checkable term becomes inferable once its type is written down.
Subtyping and conversion
The relation (subtype_nf, exposed for the empty context as def_eq) is cumulativity:
The rule is contravariant in the domain: a function that accepts every element of accepts every element of a subtype , and its results in are also results in the supertype . The rule keeps the first component invariant; covariance would also be sound, but the implementation, and its tests, require conversion there.
Conversion (conv_type) compares types structurally, entering binders with a fresh variable, and compares the endpoints of identity types with the type-directed (conv_nf), which adds :
Neutral terms are compared spine by spine (conv_neu), looking up the type of the head variable in to compare application arguments at the right type. When the head’s type is not in , as in def_eq, which has no context, the two spines are compared by their read-backs instead. That is sound, because equal read-backs are definitionally equal, but it does not use on the arguments. Everything else falls back to comparing read-backs, .
Universes
The universes are predicative and Russell style: a type is itself a term, and . There is no rule , because a universe containing itself makes the theory inconsistent (Girard’s paradox).33 J.-Y. Girard, Interprétation fonctionnelle et élimination des coupures de l’arithmétique d’ordre supérieur, thèse d’État (1972); a short proof is A. J. C. Hurkens, “A simplification of Girard’s paradox”, TLCA 1995. A type former lands in the larger universe of its parts, , which is what predicativity requires: quantifies over and therefore lives in . Cumulativity is not a typing rule but part of subtyping, used by .
Design decisions
Bidirectional checking
Problem. Inferring the type of an unannotated in a dependent type theory requires guessing its domain, which in general means higher-order unification, which is undecidable.
Options. (a) Annotate every binder, . (b) Infer with unification variables. (c) Split the terms into checked and inferred ones.
Choice. (c). Introduction forms (, pairs, , , ) are checked, because their type determines the missing information; eliminations and type formers are inferred, because the type of the head determines the type of the whole. An annotation is needed only where an introduction meets an elimination, that is, at a -redex such as , or where a motive must be a function. A term in normal form needs no annotations at all apart from the motives. Encoding the split in the types TermInf and TermChk makes an unannotated redex unrepresentable rather than a runtime error.
Values with closures
Problem. Comparing types requires evaluating them, including under binders.
Options. (a) Rewrite syntax by substitution, which needs capture-avoiding substitution and index shifting at every step. (b) Evaluate into a semantic domain where binders are host functions.
Choice. (b). A body is represented by a MoonBit function (Value) -> Value, so -reduction is a host function call and substitution never happens on syntax. Read-back recovers syntax only when needed: for printing, for comparison by , and in the checks that compare normal forms.
Indices in terms, levels in values
Terms use indices, so that -equivalence is structural equality and closed subterms do not depend on their position. Fresh variables in values use levels, Local(l) for the checker and Quote(l) for read-back, so that creating a fresh variable is a counter increment and values never need shifting. The two kinds of fresh variable are separate Name constructors, so a variable introduced by the checker cannot be mistaken for one introduced while reading back.
Subtyping instead of explicit lifts
Cumulativity could be expressed with explicit lifting operators . Building it into the change of direction instead means a type written in can be used in without any term-level coercion, as in type_chk(..., Inf(UnitType), VUniverse(1)).
Correctness and invariants
- Environment invariant. At level ,
envhas exactly entries, entry is , andctxdeclares every . The rules that go under a binder are the only places that extend the state, and they extend all three together. relies on this invariant; a caller oftype_infthat breaks it getsInternal error: Bound variable not in environment. - Evaluate only what has been checked. In every rule, a subterm is evaluated after the premise that checks it, for example the argument in and the type in . Since the evaluator panics on ill-typed redexes, this ordering is what keeps the checker total on ill-typed input: it raises
TypeErrorbefore it evaluates. - Stability of types. Every type the checker returns is a value, so the caller never has to normalise it again, and every comparison of types happens on values.
- Termination. Evaluation of a well-typed term terminates by the normalisation theorem for Martin-Löf type theory with W types and predicative universes. The checker only evaluates checked terms (invariant 2), so it terminates on every input for which its rules are sound; the gaps listed below are the exceptions.
Known gaps
The implementation is a work in progress, and some rules are weaker or stronger than the theory above. They are recorded here so that users can avoid them; the code is unchanged.
- Subtyping only for , and universes. and identity types are compared by conversion, without cumulativity in their components.
Alternatives rejected
- Typed terms with names. Named variables need capture-avoiding substitution and make -equivalence a separate check; de Bruijn indices avoid both.
- Substitution-based normalisation. Repeated syntactic substitution is slower and harder to get right than evaluation into closures, and it still needs a separate conversion check.
- Impredicative or self-containing universes. is inconsistent, and an impredicative is not part of the theory stella follows.
- Inductive families. W types provide well-founded trees with a single eliminator, which keeps the kernel small; general inductive definitions would need a positivity checker.
Boundaries
The package deliberately does not:
- parse a surface syntax, elaborate implicit arguments or solve unification problems; terms are built as MoonBit values in core syntax;
- support definitions,
letor global definitions with bodies; the context holds postulates (names with types) only; - provide universe polymorphism, inductive families, an empty type or sum types;
- implement univalence, higher inductive types or any other feature of homotopy type theory, although the treatise describes them as goals of the project;
- guarantee anything for ill-typed input to the evaluator;
eval_inf,eval_chkand theval_functions may panic.
Footnotes
-
A. Löh, C. McBride, W. Swierstra, “A tutorial implementation of a dependently typed lambda calculus”, Fundamenta Informaticae 102 (2010). U. Norell, Towards a practical programming language based on dependent type theory, PhD thesis, Chalmers (2007). ↩
-
U. Berger and H. Schwichtenberg, “An inverse of the evaluation functional for typed λ-calculus”, LICS 1991, introduced NbE. A. Abel, Normalization by Evaluation: Dependent Types and Impredicativity, habilitation, LMU Munich (2013), proves soundness and completeness of NbE for Martin-Löf type theory with . These are results about the theory; for this implementation they are tested, not proved. ↩
-
J.-Y. Girard, Interprétation fonctionnelle et élimination des coupures de l’arithmétique d’ordre supérieur, thèse d’État (1972); a short proof is A. J. C. Hurkens, “A simplification of Girard’s paradox”, TLCA 1995. ↩