debruijn design
Design goal
Named syntax needs fresh names and renaming in every binder-crossing
operation. debruijn provides a representation in which alpha-equivalence is
syntactic equality and beta reduction needs no renaming at all, together with
exact translations to and from named syntax. It is the kernel representation
of the untyped NbE in utlc/nbe and a reference reducer against
which the named calculus in utlc/lambda is tested.
Mathematical background
Indices
In a term with nameless binders, a bound occurrence is a number , its
De Bruijn index: the number of binders between the occurrence and the
binder it refers to.11 N. G. de Bruijn, “Lambda calculus notation with nameless dummies”, Indagationes Mathematicae 34, 1972. With for Bind and juxtaposition for
Apply:
The same variable has different indices at different depths (here is
and then ). Free variables stay named (Free(x)), a “locally nameless”
choice for the free part that keeps translations simple.
A term is well scoped at depth when every index under binders is
smaller than ; it is well scoped when it is well scoped at depth .
validate decides this.
Levels
The level of a binder is its depth counted from the root. An occurrence under binders with index refers to the binder at level
When a term is moved under one more binder (weakening: the context grows at its inner end), the indices of its free variables must be shifted by one, but their levels do not change. The NbE evaluator therefore uses levels for the variables it invents while going under binders, so that semantic values can be moved deeper without shifting, and converts back with when quoting.
Shifting
adds to every index of that is free relative to cutoff :
shift(t, d, c) implements it by carrying the binder depth and testing
, which unfolds the recursion on .
Substitution of an index
replaces the free index by :
substitute_bound unfolds the binder case: under binders it replaces
index by . The two agree because shifts with the
same cutoff compose (Lemma 1 below):
.
Beta reduction
The argument is shifted up because it moves under the binder of ; after
the substitution the binder is removed, so all remaining free indices of the
body are shifted down. This is instantiate(t, s).22 B. C. Pierce, Types and Programming Languages, MIT Press 2002, §6.2–6.3.
Design decisions
Shifting is checked, not assumed
Problem. A negative shift on an ill-formed term yields a negative index, which silently refers to nothing.
Choice. shift returns Err(NegativeShift) instead, and every operation
reports NegativeIndex on negative input. The lemmas below show that the
error cases are unreachable from well-scoped input, so the Result costs
nothing for correct callers and turns a silent corruption into data for
incorrect ones. This follows the library rule that expected failures at
public boundaries are structured values.
Instantiation never fails on well-scoped input
Lemma 1 (shift composition). For : , and .
On an index relative to the current cutoff: if both sides leave it; if then , so
The binder case raises the cutoff on both sides alike.
Lemma 2 (safety of the downward shift). Let and be well scoped at depth . Then every free index of lies in , so succeeds and is well scoped at depth .
The body is well scoped at depth , so its free indices lie in . Follow a free occurrence in under inner binders:
No free index remains, so the shift by maps without a negative result.
Hence instantiate and reduce_once return no ScopeError on well-scoped
input, and a reduct of a well-scoped term is well scoped (subject reduction
for scope). A ScopeFailure from normalize therefore always means that the
input was ill scoped.
The detailed proofs, including the commutation of shifts with different cutoffs and the De Bruijn substitution lemma, are in the attachment:
Exact translations
from_named keeps a stack of binder names and translates an occurrence of
to the distance to the nearest binder of , or to Free(x) if there is
none. to_named invents a binder name with fresh_name("x", U), where
contains the free names of the term and the names of the enclosing binders.
Theorem (round trips).
- for every named .
- for every well-scoped .
- .
For (2): along any path from the root, the names chosen by to_named are
pairwise distinct and distinct from all free names, because each is chosen
fresh for a set that contains the free names and every enclosing binder name.
An occurrence of index under binders becomes the name of the binder at
level ; translating back, the nearest binder with that name is that
same binder (no other enclosing binder has the name), at distance . A
Free(x) becomes , which is not the name of any binder on the path, so it
translates back to Free(x). (3) is de Bruijn’s theorem; (1) follows from (2)
and (3), since .
Named and nameless beta agree
For named terms, beta is with the capture-avoiding substitution of substitution. Write for the translation of with as the innermost binder. Then
by induction on : an occurrence of under inner binders has index and receives , which is the translation of placed under those binders; renaming of binders by the named substitution is invisible after translation by (3). Since the translation also preserves the shape of terms, the two reducers choose the same leftmost-outermost redex, and one step commutes with translation up to . The test “named and debruijn beta reduction agree modulo alpha” checks an instance that needs renaming on the named side.
Spines and reduction order
Apply(head, args) is a curried spine: Apply(Bind(t), [a, ..rest]) is the
redex applied to rest, and a step contracts only the first
argument. reduce_once searches root, head, arguments, and enters binders: it
is the normal-order strategy of the eval design on nameless terms,
so the normalization theorem applies to normalize.
Correctness / invariants
validate(from_named(t)) == Ok(())for every named .- Round trips (1)–(3) above;
==on well-scopedDbTermis alpha-equivalence. - Lemma 2: on well-scoped input
instantiatesucceeds and preserves scope;reduce_oncenever returnsScopeFailure, and every reduct is well scoped. reduce_oncesatisfies the one-redex contract of rewrite, with rule name"beta".shift(t, 0, c) == Ok(t)and shifts with one cutoff compose (Lemma 1).
Cost: shift and validate are linear in the term size. substitute_bound
costs for occurrences of the index, because each
inserted copy is shifted. A reduce_once step costs one search plus one
instantiate.
Alternatives rejected
- Levels instead of indices in syntax. Levels make weakening free but substitution under binders more complex; indices are the standard for syntax, and levels are used where they help (NbE).
- Fully nameless free variables. Free variables are kept as names so that
open terms from users and downstream ASTs need no global variable
numbering, and so that
to_namedcan restore them exactly. - Explicit substitutions. A calculus with suspended substitutions avoids repeated shifting but complicates every consumer; the NbE package provides the efficient path instead.
Boundaries
- Binder names are not preserved:
to_namedchoosesx,x_1, …. substitute_boundandreduce_oncedo not detect unbound indices; callvalidateon untrusted input.- Only beta is implemented; there is no eta rule for De Bruijn terms.
- Values are opaque; their contents are never shifted.