utlc/lambda tutorial
This tutorial computes with the untyped lambda calculus: it builds terms, normalizes them with beta and eta, encodes booleans and numbers as functions, and deals with terms that never stop reducing.
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/utlc/lambda",
}
test "quick start: (λx. x) 1" {
let x = @core.Name::new("x")
let id : @syntax.Term[Int] = @lambda.abstraction(x, Variable(x))
let term = @lambda.application(id, Value(1))
assert_eq(@lambda.normalize(term, 10), NormalForm(term=Value(1), steps=1))
}
Everyday tasks
The tasks use a few helpers to keep terms short:
fn lv(s : String) -> @syntax.Term[Int] {
@syntax.Term::Variable(@core.Name::new(s))
}
fn lam(s : String, body : @syntax.Term[Int]) -> @syntax.Term[Int] {
@lambda.abstraction(@core.Name::new(s), body)
}
fn ap(f : @syntax.Term[Int], a : @syntax.Term[Int]) -> @syntax.Term[Int] {
@lambda.application(f, a)
}
fn nf(t : @syntax.Term[Int]) -> @syntax.Term[Int] {
match @lambda.normalize(t, 1000) {
NormalForm(term~, ..) => term
StepLimitReached(..) => abort("no normal form within 1000 steps")
}
}
Encode booleans
Church booleans choose between two arguments: , , and .
test "church booleans" {
let tru = lam("t", lam("f", lv("t")))
let fls = lam("t", lam("f", lv("f")))
let not_ = lam("b", ap(ap(lv("b"), fls), tru))
assert_true(@syntax.alpha_equal(nf(ap(not_, tru)), fls))
assert_true(@syntax.alpha_equal(nf(ap(not_, fls)), tru))
}
Encode numbers and add them
The Church numeral applies a function times: . Addition is .
fn numeral(n : Int) -> @syntax.Term[Int] {
let mut body = lv("x")
for _ in 0..<n {
body = ap(lv("f"), body)
}
lam("f", lam("x", body))
}
test "2 + 2 = 4" {
let plus = lam(
"m",
lam("n", lam("f", lam("x", ap(ap(lv("m"), lv("f")), ap(ap(lv("n"), lv("f")), lv("x")))))),
)
let four = nf(ap(ap(plus, numeral(2)), numeral(2)))
assert_true(@syntax.alpha_equal(four, numeral(4)))
}
Normal forms may use different binder names (f_1, …) from your
expectation; alpha_equal ignores them.
Use eta to simplify wrappers
is just when is not free in . normalize
removes such wrappers:
test "eta removes a wrapper" {
let wrapped = lam("x", ap(lv("g"), lv("x")))
assert_eq(@lambda.normalize(wrapped, 10), NormalForm(term=lv("g"), steps=1))
let not_a_wrapper = lam("x", ap(lv("x"), lv("x")))
assert_eq(
@lambda.normalize(not_a_wrapper, 10),
NormalForm(term=not_a_wrapper, steps=0),
)
}
Handle terms that do not terminate
reduces to itself forever. The step limit turns that into a result you can test for:
test "omega hits the step limit" {
let self_apply = lam("w", ap(lv("w"), lv("w")))
let omega = ap(self_apply, self_apply)
match @lambda.normalize(omega, 50) {
StepLimitReached(steps~, ..) => assert_eq(steps, 50)
NormalForm(..) => fail("omega has no normal form")
}
// a discarded omega is harmless under normal order
assert_eq(nf(ap(lam("x", lv("y")), omega)), lv("y"))
}
Going further
Only beta, or another strategy
normalize fixes beta-eta and normal order. For other combinations, pass a
rule and a strategy to @eval.evaluate (import Luna-Flow/type_theory/eval):
test "beta only, weak head" {
let term = ap(lam("x", lam("y", ap(lv("x"), lv("y")))), lv("g"))
let rule = @rewrite.RuleName::unsafe_new("beta")
match @eval.evaluate(term, rule, @lambda.beta_rule, WeakHead, 10) {
NormalForm(term=result, steps~) => {
assert_eq(steps, 1)
assert_true(@syntax.alpha_equal(result, lam("y", ap(lv("g"), lv("y")))))
}
StepLimitReached(..) => fail("one step")
}
}
Beta alone under weak head stops at ; normalize would
also eta-reduce it to .
Domain values
Value(v) is a constant that never reduces and is copied by substitution.
Add reduction rules for constants (such as arithmetic on Value(Int)) with
your own rule, and combine it with beta_rule as shown in the
rewrite tutorial.
Faster normalization
Normal-order reduction on named terms renames binders and repeats searches
from the root. For large terms, convert with @debruijn.from_named and use
utlc/nbe.
Common pitfalls
- n-ary applications and eta.
Apply(f, [a, x])underBind(x, …)is not eta-reduced; build applications withapplication(unary) if you rely on eta. - Comparing with
==. Beta renames binders to avoid capture; use@syntax.alpha_equal. - Reading
StepLimitReachedas divergence. It only means the limit was reached. Normalizing terms can need many steps. - Expecting constants to compute.
Value(1)applied to anything stays stuck; the calculus has no built-in arithmetic.
Next steps
- utlc/lambda API for the exact contracts.
- utlc/lambda design for beta, eta and the classical theorems.
- stlc tutorial for the typed calculus, where every term normalizes.