feat: add De Bruijn index reduction engine

Closes #26

- Added -i flag to select interpreter (lambda or debruijn)
- Created debruijn package with Expression interface
  - Variable contains index and optional label
  - Abstraction contains only body (no parameter)
  - Application structure remains similar
- Implemented De Bruijn reduction without variable renaming
  - Shift operation handles index adjustments
  - Substitute replaces by index instead of name
- Abstracted Engine into interface with two implementations
  - LambdaEngine: original named variable engine
  - DeBruijnEngine: new index-based engine
- Added conversion functions between representations
  - LambdaToDeBruijn: converts named to indexed
  - DeBruijnToLambda: converts indexed back to named
  - SaccharineToDeBruijn: direct saccharine to De Bruijn
- Updated main to switch engines based on -i flag
- All test samples pass with both engines

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 21:27:40 -05:00
parent 335ce95c50
commit f3b9137d75
16 changed files with 679 additions and 63 deletions

View File

@@ -0,0 +1,63 @@
package convert
import (
"fmt"
"git.maximhutz.com/max/lambda/pkg/debruijn"
"git.maximhutz.com/max/lambda/pkg/lambda"
)
// DeBruijnToLambda converts a De Bruijn expression back to named lambda calculus.
func DeBruijnToLambda(expr debruijn.Expression) lambda.Expression {
return deBruijnToLambda(expr, []string{})
}
func deBruijnToLambda(expr debruijn.Expression, context []string) lambda.Expression {
switch e := expr.(type) {
case *debruijn.Variable:
if e.Index() >= 0 && e.Index() < len(context) {
return lambda.NewVariable(context[e.Index()])
}
if e.Label() != "" {
return lambda.NewVariable(e.Label())
}
return lambda.NewVariable(fmt.Sprintf("free_%d", e.Index()))
case *debruijn.Abstraction:
paramName := generateParamName(context)
newContext := append([]string{paramName}, context...)
body := deBruijnToLambda(e.Body(), newContext)
return lambda.NewAbstraction(paramName, body)
case *debruijn.Application:
abs := deBruijnToLambda(e.Abstraction(), context)
arg := deBruijnToLambda(e.Argument(), context)
return lambda.NewApplication(abs, arg)
default:
return nil
}
}
// generateParamName generates a fresh parameter name that doesn't conflict with context.
func generateParamName(context []string) string {
base := 'a'
for i := 0; ; i++ {
name := string(rune(base + rune(i%26)))
if i >= 26 {
name = fmt.Sprintf("%s%d", name, i/26)
}
conflict := false
for _, existing := range context {
if existing == name {
conflict = true
break
}
}
if !conflict {
return name
}
}
}

View File

@@ -0,0 +1,43 @@
package convert
import (
"git.maximhutz.com/max/lambda/pkg/debruijn"
"git.maximhutz.com/max/lambda/pkg/lambda"
)
// LambdaToDeBruijn converts a lambda expression to De Bruijn index representation.
func LambdaToDeBruijn(expr lambda.Expression) debruijn.Expression {
return lambdaToDeBruijn(expr, []string{})
}
func lambdaToDeBruijn(expr lambda.Expression, context []string) debruijn.Expression {
switch e := expr.(type) {
case *lambda.Variable:
index := findIndex(e.Value(), context)
return debruijn.NewVariable(index, e.Value())
case *lambda.Abstraction:
newContext := append([]string{e.Parameter()}, context...)
body := lambdaToDeBruijn(e.Body(), newContext)
return debruijn.NewAbstraction(body)
case *lambda.Application:
abs := lambdaToDeBruijn(e.Abstraction(), context)
arg := lambdaToDeBruijn(e.Argument(), context)
return debruijn.NewApplication(abs, arg)
default:
return nil
}
}
// findIndex returns the De Bruijn index for a variable name in the context.
// Returns the index if found, or -1 if the variable is free.
func findIndex(name string, context []string) int {
for i, v := range context {
if v == name {
return i
}
}
return -1
}

View File

@@ -0,0 +1,12 @@
package convert
import (
"git.maximhutz.com/max/lambda/pkg/debruijn"
"git.maximhutz.com/max/lambda/pkg/saccharine"
)
// SaccharineToDeBruijn converts a saccharine expression directly to De Bruijn indices.
func SaccharineToDeBruijn(expr saccharine.Expression) debruijn.Expression {
lambdaExpr := SaccharineToLambda(expr)
return LambdaToDeBruijn(lambdaExpr)
}

View File

@@ -0,0 +1,77 @@
package debruijn
type Expression interface {
Accept(Visitor)
}
/** ------------------------------------------------------------------------- */
type Abstraction struct {
body Expression
}
func (a *Abstraction) Body() Expression {
return a.body
}
func (a *Abstraction) Accept(v Visitor) {
v.VisitAbstraction(a)
}
func NewAbstraction(body Expression) *Abstraction {
return &Abstraction{body: body}
}
/** ------------------------------------------------------------------------- */
type Application struct {
abstraction Expression
argument Expression
}
func (a *Application) Abstraction() Expression {
return a.abstraction
}
func (a *Application) Argument() Expression {
return a.argument
}
func (a *Application) Accept(v Visitor) {
v.VisitApplication(a)
}
func NewApplication(abstraction Expression, argument Expression) *Application {
return &Application{abstraction: abstraction, argument: argument}
}
/** ------------------------------------------------------------------------- */
type Variable struct {
index int
label string
}
func (v *Variable) Index() int {
return v.index
}
func (v *Variable) Label() string {
return v.label
}
func (v *Variable) Accept(visitor Visitor) {
visitor.VisitVariable(v)
}
func NewVariable(index int, label string) *Variable {
return &Variable{index: index, label: label}
}
/** ------------------------------------------------------------------------- */
type Visitor interface {
VisitAbstraction(*Abstraction)
VisitApplication(*Application)
VisitVariable(*Variable)
}

68
pkg/debruijn/iterator.go Normal file
View File

@@ -0,0 +1,68 @@
package debruijn
type Iterator struct {
trace []*Expression
}
func NewIterator(expr *Expression) *Iterator {
return &Iterator{[]*Expression{expr}}
}
func (i *Iterator) Done() bool {
return len(i.trace) == 0
}
func (i *Iterator) Current() *Expression {
if i.Done() {
return nil
}
return i.trace[len(i.trace)-1]
}
func (i *Iterator) Parent() *Expression {
if len(i.trace) < 2 {
return nil
}
return i.trace[len(i.trace)-2]
}
func (i *Iterator) Swap(with Expression) {
current := i.Current()
if current != nil {
*current = with
}
}
func (i *Iterator) Back() bool {
if i.Done() {
return false
}
i.trace = i.trace[:len(i.trace)-1]
return true
}
func (i *Iterator) Next() {
switch typed := (*i.Current()).(type) {
case *Abstraction:
i.trace = append(i.trace, &typed.body)
case *Application:
i.trace = append(i.trace, &typed.abstraction)
case *Variable:
for len(i.trace) > 1 {
if app, ok := (*i.Parent()).(*Application); ok {
if app.abstraction == *i.Current() {
i.Back()
i.trace = append(i.trace, &app.argument)
return
}
}
i.Back()
}
i.trace = []*Expression{}
}
}

30
pkg/debruijn/reduce.go Normal file
View File

@@ -0,0 +1,30 @@
package debruijn
func IsViable(e *Expression) (*Abstraction, Expression, bool) {
if e == nil {
return nil, nil, false
} else if app, appOk := (*e).(*Application); !appOk {
return nil, nil, false
} else if fn, fnOk := app.abstraction.(*Abstraction); !fnOk {
return nil, nil, false
} else {
return fn, app.argument, true
}
}
func ReduceAll(e *Expression, step func()) {
it := NewIterator(e)
for !it.Done() {
if fn, arg, ok := IsViable(it.Current()); !ok {
it.Next()
} else {
it.Swap(Substitute(fn.body, arg))
step()
if _, _, ok := IsViable(it.Parent()); ok {
it.Back()
}
}
}
}

38
pkg/debruijn/stringify.go Normal file
View File

@@ -0,0 +1,38 @@
package debruijn
import (
"fmt"
"strings"
)
type stringifyVisitor struct {
builder strings.Builder
}
func (v *stringifyVisitor) VisitVariable(a *Variable) {
if a.label != "" {
v.builder.WriteString(a.label)
} else {
v.builder.WriteString(fmt.Sprintf("%d", a.index))
}
}
func (v *stringifyVisitor) VisitAbstraction(f *Abstraction) {
v.builder.WriteRune('\\')
v.builder.WriteRune('.')
f.body.Accept(v)
}
func (v *stringifyVisitor) VisitApplication(c *Application) {
v.builder.WriteRune('(')
c.abstraction.Accept(v)
v.builder.WriteRune(' ')
c.argument.Accept(v)
v.builder.WriteRune(')')
}
func Stringify(e Expression) string {
b := &stringifyVisitor{builder: strings.Builder{}}
e.Accept(b)
return b.builder.String()
}

View File

@@ -0,0 +1,68 @@
package debruijn
// Shift increments all free variable indices by delta when crossing depth abstractions.
func Shift(expr Expression, delta int, depth int) Expression {
switch e := expr.(type) {
case *Variable:
if e.index >= depth {
return NewVariable(e.index+delta, e.label)
}
return e
case *Abstraction:
newBody := Shift(e.body, delta, depth+1)
if newBody == e.body {
return e
}
return NewAbstraction(newBody)
case *Application:
newAbs := Shift(e.abstraction, delta, depth)
newArg := Shift(e.argument, delta, depth)
if newAbs == e.abstraction && newArg == e.argument {
return e
}
return NewApplication(newAbs, newArg)
default:
return expr
}
}
// Substitute replaces variable at index 0 with replacement in expr.
// This assumes expr is the body of an abstraction being applied.
func Substitute(expr Expression, replacement Expression) Expression {
return substitute(expr, 0, replacement)
}
// substitute replaces variable at targetIndex with replacement, adjusting indices as needed.
func substitute(expr Expression, targetIndex int, replacement Expression) Expression {
switch e := expr.(type) {
case *Variable:
if e.index == targetIndex {
return Shift(replacement, targetIndex, 0)
}
if e.index > targetIndex {
return NewVariable(e.index-1, e.label)
}
return e
case *Abstraction:
newBody := substitute(e.body, targetIndex+1, replacement)
if newBody == e.body {
return e
}
return NewAbstraction(newBody)
case *Application:
newAbs := substitute(e.abstraction, targetIndex, replacement)
newArg := substitute(e.argument, targetIndex, replacement)
if newAbs == e.abstraction && newArg == e.argument {
return e
}
return NewApplication(newAbs, newArg)
default:
return expr
}
}