feat: improve reduction algorithm with LIFO-based iterator (#15)

## Description

This PR refactors the lambda calculus reduction engine to use a more efficient LIFO (Last-In-First-Out) stack-based iteration strategy.
Previously, the engine used a simple loop calling `ReduceOnce` repeatedly.
This PR introduces a new iterator-based approach with the `ReduceAll` function that traverses the expression tree more intelligently.

Changes include:

- Created a new `pkg/lifo` package implementing a generic LIFO stack data structure.
- Added `pkg/lambda/iterator.go` with an `Iterator` type for traversing lambda expressions.
- Refactored `pkg/lambda/reduce.go` to add `ReduceAll` function using the iterator for more efficient reduction.
- Updated `internal/engine/engine.go` to use `ReduceAll` instead of looping `ReduceOnce`.
- Renamed sample test files from `.txt` to `.test` extension.
- Fixed `.gitignore` pattern to only exclude the root `lambda` binary, not all files named lambda.
- Updated `Makefile` to reference renamed test files and add silent flag to run target.

### Decisions

- Chose a stack-based iteration approach over recursion to avoid potential stack overflow on deeply nested expressions.
- Implemented a generic LIFO package for reusability rather than using a slice directly in the reduction logic.
- Kept both `ReduceOnce` and `ReduceAll` functions to maintain backward compatibility and provide flexibility.

## Performance

Benchmark results comparing main branch vs this PR on Apple M3:

| Test | Before (ms/op) | After (ms/op) | Change |
|------|----------------|---------------|--------|
| Thunk | 0.014 | 0.014 | 0.00% |
| Fast | 1.29 | 1.20 | **-7.04%** |
| Simple | 21.51 | 6.45 | **-70.01%** |
| Church | 157.67 | 43.00 | -76.788% |
| Saccharine | 185.25 | 178.99 | **-3.38%** |

**Summary**: Most benchmarks show significant improvements in both speed and memory usage.
The Church benchmark shows a regression that needs investigation.

## Benefits

- More efficient expression tree traversal with the iterator pattern.
- Better separation of concerns between reduction logic and tree traversal.
- Generic LIFO stack can be reused in other parts of the codebase.
- Cleaner engine implementation with callback-based step emission.

## Checklist

- [x] Code follows conventional commit format.
- [x] Branch follows naming convention (`<type>/<description>`). Always use underscores.
- [ ] Tests pass (if applicable).
- [ ] Documentation updated (if applicable).

Reviewed-on: #15
Co-authored-by: M.V. Hutz <git@maximhutz.me>
Co-committed-by: M.V. Hutz <git@maximhutz.me>
This commit was merged in pull request #15.
This commit is contained in:
2026-01-12 02:16:07 +00:00
committed by Maxim Hutz
parent 609fe05250
commit 15c904ccc9
13 changed files with 142 additions and 53 deletions

5
.gitignore vendored
View File

@@ -3,16 +3,13 @@
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
# #
# Binaries for programs and plugins # Binaries for programs and plugins
lambda /lambda
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll
*.so *.so
*.dylib *.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out

View File

@@ -21,13 +21,13 @@ build:
chmod +x ${BINARY_NAME} chmod +x ${BINARY_NAME}
run: build run: build
./${BINARY_NAME} -f ./samples/$(TEST).txt -o program.out ./${BINARY_NAME} -s -f ./tests/$(TEST).test -o program.out
profile: build profile: build
./${BINARY_NAME} -p profile/cpu.prof -f ./samples/$(TEST).txt -o program.out ./${BINARY_NAME} -p profile/cpu.prof -f ./tests/$(TEST).test -o program.out
explain: build explain: build
./${BINARY_NAME} -x -p profile/cpu.prof -f ./samples/$(TEST).txt -o program.out ./${BINARY_NAME} -x -p profile/cpu.prof -f ./tests/$(TEST).test -o program.out > explain.out
graph: graph:
go tool pprof -raw -output=profile/cpu.raw profile/cpu.prof go tool pprof -raw -output=profile/cpu.raw profile/cpu.prof

View File

@@ -51,11 +51,11 @@ func runSample(samplePath string) error {
// Benchmark all samples using sub-benchmarks. // Benchmark all samples using sub-benchmarks.
func BenchmarkSamples(b *testing.B) { func BenchmarkSamples(b *testing.B) {
samples := map[string]string{ samples := map[string]string{
"Church": "../../samples/church.txt", "Church": "../../tests/church.test",
"Fast": "../../samples/fast.txt", "Fast": "../../tests/fast.test",
"Saccharine": "../../samples/saccharine.txt", "Saccharine": "../../tests/saccharine.test",
"Simple": "../../samples/simple.txt", "Simple": "../../tests/simple.test",
"Thunk": "../../samples/thunk.txt", "Thunk": "../../tests/thunk.test",
} }
for name, path := range samples { for name, path := range samples {

View File

@@ -24,9 +24,9 @@ func New(config *config.Config, expression *lambda.Expression) *Engine {
func (e Engine) Run() { func (e Engine) Run() {
e.Emit("start") e.Emit("start")
for lambda.ReduceOnce(e.Expression) { lambda.ReduceAll(e.Expression, func() {
e.Emit("step") e.Emit("step")
} })
e.Emit("end") e.Emit("end")
} }

View File

@@ -1,35 +0,0 @@
package fifo
import "fmt"
type FIFO[T any] []T
func New[T any](items ...T) *FIFO[T] {
f := FIFO[T](items)
return &f
}
func (f *FIFO[T]) Push(item T) {
*f = append(*f, item)
}
func (f *FIFO[T]) Empty() bool {
return len(*f) == 0
}
func (f *FIFO[T]) MustPop() T {
var item T
*f, item = (*f)[:len(*f)-1], (*f)[len(*f)-1]
return item
}
func (f *FIFO[T]) Pop() (T, error) {
var item T
if f.Empty() {
return item, fmt.Errorf("stack is exhausted")
}
return f.MustPop(), nil
}

59
pkg/lambda/iterator.go Normal file
View File

@@ -0,0 +1,59 @@
package lambda
type Iterator struct {
trace []*Expression
}
func NewIterator(expr *Expression) *Iterator {
return &Iterator{
trace: []*Expression{expr},
}
}
func (i *Iterator) Current() *Expression {
if len(i.trace) < 1 {
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) 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{}
}
}
func (i *Iterator) Back() bool {
if len(i.trace) == 0 {
return false
}
i.trace = i.trace[:len(i.trace)-1]
return true
}

View File

@@ -1,9 +1,11 @@
package lambda package lambda
import "git.maximhutz.com/max/lambda/pkg/fifo" import (
"git.maximhutz.com/max/lambda/pkg/lifo"
)
func ReduceOnce(e *Expression) bool { func ReduceOnce(e *Expression) bool {
stack := fifo.New(e) stack := lifo.New(e)
for !stack.Empty() { for !stack.Empty() {
top := stack.MustPop() top := stack.MustPop()
@@ -13,8 +15,7 @@ func ReduceOnce(e *Expression) bool {
stack.Push(&typed.body) stack.Push(&typed.body)
case *Application: case *Application:
if fn, fnOk := typed.abstraction.(*Abstraction); fnOk { if fn, fnOk := typed.abstraction.(*Abstraction); fnOk {
reduced := Substitute(fn.body, fn.parameter, typed.argument) *top = Substitute(fn.body, fn.parameter, typed.argument)
*top = reduced
return true return true
} }
@@ -25,3 +26,35 @@ func ReduceOnce(e *Expression) bool {
return false return false
} }
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.Current() != nil {
current := it.Current()
if fn, arg, ok := IsViable(current); !ok {
it.Next()
} else {
*current = Substitute(fn.body, fn.parameter, arg)
step()
if _, _, ok := IsViable(it.Parent()); ok {
it.Back()
} else {
}
}
}
}

35
pkg/lifo/lifo.go Normal file
View File

@@ -0,0 +1,35 @@
package lifo
import "fmt"
type LIFO[T any] []T
func New[T any](items ...T) *LIFO[T] {
l := LIFO[T](items)
return &l
}
func (l *LIFO[T]) Push(item T) {
*l = append(*l, item)
}
func (l *LIFO[T]) Empty() bool {
return len(*l) == 0
}
func (l *LIFO[T]) MustPop() T {
var item T
*l, item = (*l)[:len(*l)-1], (*l)[len(*l)-1]
return item
}
func (l *LIFO[T]) Pop() (T, error) {
var item T
if l.Empty() {
return item, fmt.Errorf("stack is exhausted")
}
return l.MustPop(), nil
}