utlc/nbe design
Design goal
Small-step normalization repeats a search from the root for every beta step and copies the term each time. Normalization by evaluation (NbE) instead interprets a term as a value of the host language, where beta reduction is just function application, and reads the value back as a normal form. This package provides NbE for the untyped calculus on De Bruijn terms: fast, but bounded by fuel, because untyped terms need not normalize. The operational reducers remain the reference semantics; NbE is checked against them.
Mathematical background
The semantic domain
Values are given by the following grammar, where is an environment (a list of values, index first), a De Bruijn term, a free name and a level:
is the value of in ; is an argument not yet evaluated; neutral values are computations stuck on a variable. In the implementation, also arises when is a constant, since a constant applied to an argument is stuck as well.
Evaluation
evaluates to weak head form:
Arguments are delayed, so evaluation is call by name: an argument that is never used is never evaluated. There is no memoization; a delayed argument used twice is evaluated twice.
Readback
reads back under binders:
A closure is read back by applying it to a fresh variable. That variable is
represented by its level , which does not change while the value is
carried under further binders, and converted to the index at
the occurrence (see levels in the debruijn design).
normalize(t) is for closed .
Design decisions
Untyped NbE needs a budget
Problem. For , evaluation unfolds forever. In the typed setting termination is a theorem (stlc design); here it is false.
Choice. Every evaluation of a node, every force and every readback
step costs one unit of fuel, and running out yields FuelExhausted with the
amount consumed. The budget is threaded through all phases, so the cost of
reading back under binders, which may evaluate closure bodies, is included.
Determinism in the budget. The computation does not inspect the fuel
except to stop, so a run with fuel that ends with a normal form after
consuming units performs exactly the same computation with any fuel
. Results are therefore reproducible, and consumed is the exact
minimum budget for that result.
Laziness without sharing
Problem. Strict evaluation (call by value) diverges on , although the term has the normal form .
Choice. Arguments are delayed. Together with readback, which evaluates the head of every neutral first and its arguments afterwards, and enters closures only after their head is known, this realizes normal-order (leftmost-outermost) reduction, the normalizing strategy of the eval design. Call by need would share delayed results; it is not used because it needs mutable thunks, and the budget keeps the cost of recomputation bounded and visible.
Opaque semantic values
Semantic[T] is a struct around a private enum. Callers can create neutral
values (reflect_free, reflect_level), evaluate, and quote, but cannot
build closures with ill-scoped environments. This keeps the invariant that
every closure environment matches the scope of its body, which is why eval
validates its input once and can then trust every index lookup.
Unary applications in normal forms
Readback produces as a unary
Apply, so the spine is returned as Apply(Apply(f, [a]), [b]).
The small-step reducer keeps the n-ary Apply(f, [a, b]) of its input. Both
denote the same curried application; comparisons between the two normalizers
must flatten spines first.
Correctness / invariants
Theorem (soundness). If normalize(t, fuel) returns NormalForm(u, _),
then is beta-normal and (up to spine flattening).
Sketch. Define the denotation of a value under binders as the term it stands for: , , , and so on, where substitutes the denotations of for the free indices of . By induction on evaluation, : the only non-trivial case is , which is the beta step (substitution lemma of the debruijn attachment). Readback inserts only beta steps under binders, so . For normality: produces only from closures, and applications only from , whose head is a neutral or a constant, never a closure (application of a closure is evaluated, not stored). Hence the output follows the grammar
which contains no redex .
Completeness (sketch). If has a beta normal form, normalize
returns it for sufficient fuel. Evaluation computes the weak head normal form
by call by name, and readback recursively normalizes the body of a closure
and the arguments of a neutral after its head: this is the leftmost-outermost
strategy decomposed into head and arguments. By the normalization theorem
(Barendregt 13.2.2) this strategy terminates on every term with a normal
form, and by confluence the normal form is unique.11 For untyped NbE see K. Aehlig and F. Joachimski, “Operational aspects of untyped normalisation by evaluation”, Mathematical Structures in Computer Science 14, 2004; for strong reduction by evaluation, B. Grégoire and X. Leroy, “A compiled implementation of strong reduction”, ICFP 2002.
Agreement with small-step reduction. By soundness and confluence,
whenever both @nbe.normalize and @debruijn.normalize return a normal form
for the same term, the two normal forms are equal after spine flattening. The
tests in src/utlc/nbe/nbe_test.mbt check this, laziness on
, fuel exhaustion on , and that quote turns
levels back into indices.
Other invariants:
evalandnormalizereject ill-scoped input withScopeFailureandconsumed=0before doing any work.quote(value, n, _)returnsScopeFailureonly when a level variable is not below , which cannot happen for values produced byevaland quoted at level 0.- Fuel:
consumednever exceeds the fuel given; the determinism property above holds.
Alternatives rejected
- Typed or eta-long readback. Untyped readback cannot know where to eta-expand; eta-long normal forms are provided for typed terms by stlc.
- Host-language closures (HOAS). Representing as a MoonBit function would be faster but makes fuel accounting and inspection of values impossible, and the values could not be quoted without a fresh-variable trick on the host side. A first-order closure keeps everything observable.
- Named environments. Indexing the environment by De Bruijn index makes lookup positional and needs no fresh names during evaluation.
Boundaries
- Beta only: no eta, no reduction of constants.
- Not total:
FuelExhaustedis the expected outcome for divergent terms and does not prove divergence. - No sharing of delayed arguments.
- Normal forms use unary applications.
- Input must be De Bruijn syntax; convert named terms with
@debruijn.from_namedand results back with@debruijn.to_named.
Footnotes
-
For untyped NbE see K. Aehlig and F. Joachimski, “Operational aspects of untyped normalisation by evaluation”, Mathematical Structures in Computer Science 14, 2004; for strong reduction by evaluation, B. Grégoire and X. Leroy, “A compiled implementation of strong reduction”, ICFP 2002. ↩