debruijn tutorial
This tutorial converts named lambda terms to De Bruijn form, uses that form to compare terms and to reduce them without renaming, validates untrusted nameless input, and converts results back to names for display.
Quick start
moon add Luna-Flow/type_theory@0.2.0
import {
"Luna-Flow/type_theory/core",
"Luna-Flow/type_theory/syntax",
"Luna-Flow/type_theory/rewrite",
"Luna-Flow/type_theory/debruijn",
}
becomes : the variable refers to the binder one level further out.
test "quick start: the K combinator without names" {
let x = @core.Name::new("x")
let y = @core.Name::new("y")
let k : @syntax.Term[Int] = Bind(x, Bind(y, Variable(x)))
assert_eq(@debruijn.from_named(k), Bind(Bind(Bound(1))))
}
Everyday tasks
Compare terms up to bound names
Alpha-equivalent named terms have equal De Bruijn forms, so == after
conversion decides alpha-equivalence:
test "alpha-equivalence by conversion" {
let a : @syntax.Term[Int] = Bind(@core.Name::new("p"), Variable(@core.Name::new("p")))
let b : @syntax.Term[Int] = Bind(@core.Name::new("q"), Variable(@core.Name::new("q")))
assert_eq(@debruijn.from_named(a), @debruijn.from_named(b))
}
@syntax.alpha_equal gives the same answer without building the converted
terms; the conversion pays off when you keep the nameless form for further
work.
Reduce without renaming
Beta reduction on nameless terms shifts indices instead of renaming binders. The classic capture example needs no fresh name:
test "beta without capture problems" {
let y = @core.Name::new("y")
let term : @debruijn.DbTerm[Int] = Apply(Bind(Bind(Bound(1))), [Free(y)])
assert_eq(
@debruijn.normalize(term, 10),
NormalForm(term=Bind(Free(y)), steps=1),
)
}
The free y stays free because it is a name, and the inner binder needs no
new name because it has none.
Validate untrusted input
Terms built by hand or read from outside can contain indices that point past every binder. Check them before reducing:
test "reject a dangling index" {
let bad : @debruijn.DbTerm[Int] = Bind(Apply(Bound(0), [Bound(2)]))
match @debruijn.validate(bad) {
Err(UnboundIndex(index~, depth~)) => {
assert_eq(index, 2)
assert_eq(depth, 1)
}
_ => fail("expected an unbound index")
}
}
Convert back to names for display
to_named names binders x, x_1, … and avoids the free names of the term:
test "readable names come back" {
let x = @core.Name::new("x")
let term : @debruijn.DbTerm[Int] = Bind(Bind(Apply(Bound(1), [Free(x)])))
match @debruijn.to_named(term) {
Ok(Bind(outer, Bind(inner, _))) => {
inspect(outer.text(), content="x_1")
inspect(inner.text(), content="x_2")
}
_ => fail("well scoped")
}
}
The binders skip x because x is free in the term.
Open a binder by hand
instantiate(body, arg) is the result of . Use it when
you implement your own reducer or evaluator:
test "open a binder" {
// body of λ. (λ. 1 0): the outer variable applied to the inner one
let body : @debruijn.DbTerm[Int] = Bind(Apply(Bound(1), [Bound(0)]))
assert_eq(
@debruijn.instantiate(body, Value(9)),
Ok(Bind(Apply(Value(9), [Bound(0)]))),
)
}
Going further
Check the named reducer against the nameless one
Because the conversion commutes with beta reduction, the named and nameless reducers must agree up to alpha-equivalence. This is a good property test for any reducer you write:
fn named_beta(t : @syntax.Term[Int]) -> @syntax.Term[Int]? {
match t {
Apply(Bind(x, body), [arg]) =>
Some(@substitution.Substitution::singleton(x, arg).apply(body))
_ => None
}
}
test "named and nameless beta agree" {
let x = @core.Name::new("x")
let y = @core.Name::new("y")
let named : @syntax.Term[Int] = Apply(Bind(x, Bind(y, Apply(Variable(x), [Variable(y)]))), [
Variable(y),
])
match (named_beta(named), @debruijn.reduce_once(@debruijn.from_named(named))) {
(Some(n), Reduced(after~, ..)) => assert_eq(@debruijn.from_named(n), after)
_ => fail("both reduce")
}
}
(This example also imports Luna-Flow/type_theory/substitution.)
Fast normalization
normalize repeats one search-and-contract step from the root, which is
simple and traceable but slow on long reductions. For large untyped terms use
utlc/nbe, which works on the same DbTerm and returns the same
normal forms (up to the shape of application spines).
Common pitfalls
- Counting indices from the outside.
Bound(0)is the innermost binder. isBind(Bind(Bound(1))), notBound(0). - Reducing unvalidated terms.
reduce_onceandsubstitute_bounddo not detect unbound indices. Callvalidatefirst on input you did not build withfrom_named. - Expecting binder names to survive.
to_named(from_named(t))is alpha-equivalent tot, but binder names are regenerated. - Spines.
Apply(f, [a, b])andApply(Apply(f, [a]), [b])mean the same application but are different values; normalize them to one shape before comparing with==.
Next steps
- debruijn API for every function and error case.
- debruijn design for shifting, instantiation and the correctness lemmas.
- utlc/nbe tutorial for normalization by evaluation on
DbTerm.