Compare commits

6 Commits

Author SHA1 Message Date
528956b033 feat: add De Bruijn indexed reduction engine
Add a new interpreter option (-i debruijn) that uses De Bruijn indices
for variable representation, eliminating the need for variable renaming
during substitution.

- Add -i flag to select interpreter (lambda or debruijn)
- Create debruijn package with Expression types (Variable with index,
  Abstraction without parameter, Application)
- Implement shift and substitute operations for De Bruijn indices
- Add conversion functions between lambda and De Bruijn representations
- Update CLI to support switching between interpreters
- Add De Bruijn tests to verify all samples pass

Closes #26
2026-01-16 19:36:05 -05:00
1974ad582f refactor: move event system to reducer, remove engine package (#32)
## Description

This PR completes the MVC-inspired refactoring by moving the event system from the engine into the reducer.
The engine package is now removed entirely, as the reducer handles both reduction logic and lifecycle events.

- Add `pkg/reducer/events.go` with `StartEvent`, `StepEvent`, and `StopEvent`.
- Extend `Reducer` interface to embed `Emitter[Event]` and add `Expression()` method.
- Update `NormalOrderReducer` to embed `BaseEmitter` and emit lifecycle events during reduction.
- Update all plugins to attach to `Reducer` instead of `Engine`.
- Remove `internal/engine` package entirely.
- Add `Off()` method to `BaseEmitter` to complete the `Emitter` interface.
- Fix `Emitter.On` signature to use generic type `E` instead of `string`.

### Decisions

- The `Reducer` interface now combines reduction logic with event emission, making it the single orchestration point.
- Plugins attach directly to the reducer, simplifying the architecture.
- The `Expression()` method on `Reducer` provides access to current state for plugins.

## Benefits

- Simpler architecture with one fewer abstraction layer.
- Plugins are now mode-agnostic - they work with any `Reducer` implementation.
- Cleaner separation: reducers handle reduction, plugins observe via events.
- Easier to add new evaluation modes - just implement `Reducer` with embedded emitter.

## Checklist

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

Reviewed-on: #32
Co-authored-by: M.V. Hutz <git@maximhutz.me>
Co-committed-by: M.V. Hutz <git@maximhutz.me>
2026-01-17 00:27:36 +00:00
f8e1223463 refactor: extract Reducer interface and update engine to use abstractions (#31)
## Description

This PR builds on #30 to complete the abstraction layer for multi-mode evaluation support.
The engine now accepts abstract `expr.Expression` and `reducer.Reducer` types instead of concrete lambda types.

- Add `pkg/reducer/reducer.go` with `Reducer` interface defining `Reduce(expr.Expression, onStep) expr.Expression`.
- Add `pkg/lambda/reducer.go` with `NormalOrderReducer` that wraps the existing `ReduceAll` logic.
- Update `engine.Engine` to store `expr.Expression` and `reducer.Reducer` instead of `*lambda.Expression`.
- Update plugins to use `expr.Expression.String()` directly (no pointer dereference needed).
- Update main and tests to instantiate `NormalOrderReducer` and pass it to the engine.

### Decisions

- The `Reducer.Reduce` method returns the final expression and calls `onStep` after each reduction step with the current state.
- `NormalOrderReducer` type-asserts to `lambda.Expression` internally; other expression types are returned unchanged.
- The engine updates its `Expression` field both during reduction (via `onStep`) and after completion.

## Benefits

- The engine is now fully decoupled from lambda-specific types.
- New evaluation modes can be added by implementing `expr.Expression` and `reducer.Reducer`.
- Plugins work with any expression type that implements `expr.Expression`.
- Prepares the codebase for SKI combinators, typed lambda calculus, or other future modes.

## Checklist

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

Closes #30

Reviewed-on: #31
Co-authored-by: M.V. Hutz <git@maximhutz.me>
Co-committed-by: M.V. Hutz <git@maximhutz.me>
2026-01-16 23:42:07 +00:00
e0114c736d refactor: extract abstract Expression interface (#30)
## Description

The codebase currently couples the engine and plugins directly to `lambda.Expression`.
This PR introduces an abstract `expr.Expression` interface to enable future support for multiple evaluation modes.

- Add `pkg/expr/expr.go` with an `Expression` interface requiring a `String()` method.
- Update `lambda.Expression` to embed `expr.Expression`.
- Add `String()` method to `Abstraction`, `Application`, and `Variable` types.
- Update plugins to use `String()` instead of `lambda.Stringify()`.

### Decisions

- The `expr.Expression` interface is minimal (only `String()`) to avoid over-constraining future expression types.
- The engine still stores `*lambda.Expression` directly rather than `expr.Expression`, because Go's interface semantics require pointer indirection for in-place mutation during reduction.
- Future evaluation modes will implement their own concrete types satisfying `expr.Expression`.

## Benefits

- Establishes a foundation for supporting multiple evaluation modes (SKI combinators, typed lambda calculus, etc.).
- Plugins now use the abstract `String()` method, making them more decoupled from the lambda-specific implementation.
- Prepares the codebase for a Reducer interface abstraction in a future PR.

## Checklist

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

Reviewed-on: #30
Co-authored-by: M.V. Hutz <git@maximhutz.me>
Co-committed-by: M.V. Hutz <git@maximhutz.me>
2026-01-16 23:37:31 +00:00
5c54f4e195 fix: correct event handler registration in plugins (#29)
## Description

This PR fixes incorrect event handler registration in two plugins that were introduced in the refactoring.
The bugs prevented the plugins from functioning as intended.

Fixed issues:
- Statistics plugin was registering `plugin.Step` for `StopEvent` instead of `plugin.Stop`, preventing statistics from being printed at the end of execution.
- Logs plugin was listening to `StopEvent` instead of `StepEvent`, causing it to log only once at the end instead of on each reduction step.

## Benefits

Statistics are now correctly printed at the end of execution.
Debug logs now correctly show each reduction step instead of just the final state.
Plugins now work as originally intended before the refactoring.

## Checklist

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

Reviewed-on: #29
Co-authored-by: M.V. Hutz <git@maximhutz.me>
Co-committed-by: M.V. Hutz <git@maximhutz.me>
2026-01-14 00:35:02 +00:00
307b7ffd1e refactor: replace string-based emitter with type-safe generic event system (#28)
## Description

This PR refactors the event emitter system from a string-based message passing approach to a type-safe generic implementation using typed events.
The previous system relied on string message names which were error-prone and lacked compile-time safety.
This refactoring introduces a generic `BaseEmitter[E comparable]` that provides type safety while consolidating the various tracker packages into a unified plugins architecture.

Key changes:
- Replace `Emitter` with generic `BaseEmitter[E comparable]` for type-safe event handling.
- Add `Event` type enumeration with `StartEvent`, `StepEvent`, and `StopEvent` constants.
- Create `Listener[E]` interface with `BaseListener` implementation for better abstraction.
- Consolidate `explanation`, `performance`, and `statistics` packages into unified `internal/plugins` package.
- Simplify CLI initialization by using plugin constructors that handle their own event subscriptions.
- Add `Items()` iterator method to `Set` for idiomatic Go 1.23+ range loops over sets.

### Decisions

Use generics for type-safe event handling.
This provides compile-time guarantees that event types match their handlers while maintaining flexibility for future event types.

Consolidate trackers into plugins architecture.
Previously separate packages (`explanation`, `performance`, `statistics`) now live under `internal/plugins`, making the plugin pattern explicit and easier to extend.

Plugin constructors self-register with engine.
Each plugin's `New*` constructor now handles its own event subscriptions, reducing boilerplate in the main CLI.

## Benefits

Type safety prevents runtime errors from typos in event names.
The compiler now catches mismatched event types at compile time rather than failing silently at runtime.

Cleaner plugin architecture makes adding new features easier.
New plugins follow a consistent pattern and live in a single location.

Reduced boilerplate in main CLI.
Plugin initialization is now a single function call rather than manual event registration.

Better testability through interface-based design.
The `Listener[E]` interface allows for easier mocking and testing of event handlers.

## Checklist

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

Reviewed-on: #28
Co-authored-by: M.V. Hutz <git@maximhutz.me>
Co-committed-by: M.V. Hutz <git@maximhutz.me>
2026-01-14 00:30:21 +00:00
34 changed files with 716 additions and 593 deletions

View File

@@ -80,3 +80,26 @@ Use the `tea` CLI (Gitea command-line tool) for PR operations instead of `gh`.
**Linking issues**: When a PR solves an issue, reference the issue in both the commit message and PR description using `Closes #<number>`. **Linking issues**: When a PR solves an issue, reference the issue in both the commit message and PR description using `Closes #<number>`.
This automatically links and closes the issue when the PR is merged. This automatically links and closes the issue when the PR is merged.
### Updating PRs
When pushing additional changes to an existing PR, add a comment summarizing the new commits.
This keeps reviewers informed of what changed since the initial PR description.
Use the `tea` CLI to add comments to pull requests:
```bash
tea comment <number> "Comment text"
```
#### Examples
```bash
# Add a comment to PR #42
tea comment 42 "Updated implementation based on feedback"
# Add a multi-line comment
tea comment 42 "Summary of changes:
- Fixed bug in reducer
- Added new tests"
```

View File

@@ -1,98 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"git.maximhutz.com/max/lambda/internal/config"
"git.maximhutz.com/max/lambda/internal/engine"
"git.maximhutz.com/max/lambda/pkg/convert"
"git.maximhutz.com/max/lambda/pkg/saccharine"
)
func TestEngineEquivalence(t *testing.T) {
testsDir := "../../tests"
files, err := os.ReadDir(testsDir)
if err != nil {
t.Fatalf("Failed to read tests directory: %v", err)
}
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".test") {
continue
}
testName := strings.TrimSuffix(file.Name(), ".test")
t.Run(testName, func(t *testing.T) {
// Read test input
inputPath := filepath.Join(testsDir, file.Name())
input, err := os.ReadFile(inputPath)
if err != nil {
t.Fatalf("Failed to read test file: %v", err)
}
// Parse syntax tree
ast, err := saccharine.Parse(string(input))
if err != nil {
t.Fatalf("Failed to parse input: %v", err)
}
// Test lambda engine
lambdaExpr := convert.SaccharineToLambda(ast)
lambdaCfg := &config.Config{Interpreter: "lambda"}
lambdaEngine := engine.NewLambdaEngine(lambdaCfg, &lambdaExpr)
lambdaEngine.Run()
lambdaResult := lambdaEngine.GetResult()
// Test De Bruijn engine
debruijnExpr := convert.SaccharineToDeBruijn(ast)
debruijnCfg := &config.Config{Interpreter: "debruijn"}
debruijnEngine := engine.NewDeBruijnEngine(debruijnCfg, &debruijnExpr)
debruijnEngine.Run()
debruijnResult := debruijnEngine.GetResult()
// Convert De Bruijn result back to lambda for comparison
debruijnConverted := convert.DeBruijnToLambda(*debruijnEngine.Expression)
debruijnConvertedStr := convert.DeBruijnToLambda(*debruijnEngine.Expression)
// Check if expected file exists
expectedPath := filepath.Join(testsDir, testName+".expected")
if expectedBytes, err := os.ReadFile(expectedPath); err == nil {
expected := strings.TrimSpace(string(expectedBytes))
if lambdaResult != expected {
t.Errorf("Lambda engine result mismatch:\nExpected: %s\nGot: %s", expected, lambdaResult)
}
// De Bruijn result will have different variable names, so we just check it runs
if debruijnResult == "" {
t.Errorf("De Bruijn engine produced empty result")
}
}
// Log results for comparison
t.Logf("Lambda result: %s", lambdaResult)
t.Logf("De Bruijn result: %s", debruijnResult)
t.Logf("De Bruijn converted: %v", debruijnConvertedStr)
_ = debruijnConverted // Suppress unused warning
})
}
}
func TestInvalidInterpreterFlag(t *testing.T) {
// This would be tested at the config level
cfg := &config.Config{Interpreter: "invalid"}
// The validation happens in FromArgs, but we can test the engine creation
// doesn't panic with invalid values
defer func() {
if r := recover(); r != nil {
t.Errorf("Engine creation panicked with invalid interpreter: %v", r)
}
}()
// Just check that default behavior works
_ = cfg
}

View File

@@ -1,17 +1,15 @@
package main package main
import ( import (
"fmt"
"os" "os"
"git.maximhutz.com/max/lambda/internal/cli" "git.maximhutz.com/max/lambda/internal/cli"
"git.maximhutz.com/max/lambda/internal/config" "git.maximhutz.com/max/lambda/internal/config"
"git.maximhutz.com/max/lambda/internal/engine" "git.maximhutz.com/max/lambda/internal/plugins"
"git.maximhutz.com/max/lambda/internal/performance"
"git.maximhutz.com/max/lambda/internal/statistics"
"git.maximhutz.com/max/lambda/pkg/convert" "git.maximhutz.com/max/lambda/pkg/convert"
"git.maximhutz.com/max/lambda/pkg/debruijn" "git.maximhutz.com/max/lambda/pkg/debruijn"
"git.maximhutz.com/max/lambda/pkg/lambda" "git.maximhutz.com/max/lambda/pkg/lambda"
"git.maximhutz.com/max/lambda/pkg/reducer"
"git.maximhutz.com/max/lambda/pkg/saccharine" "git.maximhutz.com/max/lambda/pkg/saccharine"
) )
@@ -33,92 +31,55 @@ func main() {
cli.HandleError(err) cli.HandleError(err)
logger.Info("parsed syntax tree", "tree", ast) logger.Info("parsed syntax tree", "tree", ast)
// Create reduction engine based on interpreter type. // Compile expression to lambda calculus.
var process engine.Engine compiled := convert.SaccharineToLambda(ast)
if options.Interpreter == "debruijn" { logger.Info("compiled λ expression", "tree", compiled.String())
// Compile expression to De Bruijn indices.
compiled := convert.SaccharineToDeBruijn(ast)
logger.Info("compiled De Bruijn expression", "tree", debruijn.Stringify(compiled))
dbEngine := engine.NewDeBruijnEngine(options, &compiled)
// If the user selected to track CPU performance, attach a profiler. // Create reducer based on the selected interpreter.
if options.Profile != "" { var red reducer.Reducer
profiler := performance.Track(options.Profile) switch options.Interpreter {
dbEngine.On("start", profiler.Start) case config.DeBruijnInterpreter:
dbEngine.On("end", profiler.End) dbExpr := convert.LambdaToDeBruijn(compiled)
} logger.Info("converted to De Bruijn", "tree", dbExpr.String())
red = debruijn.NewNormalOrderReducer(&dbExpr)
// If the user selected to produce a step-by-step explanation, print steps. default:
if options.Explanation { red = lambda.NewNormalOrderReducer(&compiled)
dbEngine.On("start", func() {
fmt.Println(debruijn.Stringify(*dbEngine.Expression))
})
dbEngine.On("step", func() {
fmt.Println(" =", debruijn.Stringify(*dbEngine.Expression))
})
}
// If the user opted to track statistics, attach a tracker.
if options.Statistics {
statistics := statistics.Track()
dbEngine.On("start", statistics.Start)
dbEngine.On("step", statistics.Step)
dbEngine.On("end", statistics.End)
}
// If the user selected for verbose debug logs, attach a reduction tracker.
if options.Verbose {
dbEngine.On("step", func() {
logger.Info("reduction", "tree", debruijn.Stringify(*dbEngine.Expression))
})
}
process = dbEngine
} else {
// Compile expression to lambda calculus.
compiled := convert.SaccharineToLambda(ast)
logger.Info("compiled λ expression", "tree", lambda.Stringify(compiled))
lambdaEngine := engine.NewLambdaEngine(options, &compiled)
// If the user selected to track CPU performance, attach a profiler.
if options.Profile != "" {
profiler := performance.Track(options.Profile)
lambdaEngine.On("start", profiler.Start)
lambdaEngine.On("end", profiler.End)
}
// If the user selected to produce a step-by-step explanation, print steps.
if options.Explanation {
lambdaEngine.On("start", func() {
fmt.Println(lambda.Stringify(*lambdaEngine.Expression))
})
lambdaEngine.On("step", func() {
fmt.Println(" =", lambda.Stringify(*lambdaEngine.Expression))
})
}
// If the user opted to track statistics, attach a tracker.
if options.Statistics {
statistics := statistics.Track()
lambdaEngine.On("start", statistics.Start)
lambdaEngine.On("step", statistics.Step)
lambdaEngine.On("end", statistics.End)
}
// If the user selected for verbose debug logs, attach a reduction tracker.
if options.Verbose {
lambdaEngine.On("step", func() {
logger.Info("reduction", "tree", lambda.Stringify(*lambdaEngine.Expression))
})
}
process = lambdaEngine
} }
process.Run() // If the user selected to track CPU performance, attach a profiler.
if options.Profile != "" {
plugins.NewPerformance(options.Profile, red)
}
// If the user selected to produce a step-by-step explanation, attach an
// observer.
if options.Explanation {
plugins.NewExplanation(red)
}
// If the user opted to track statistics, attach a tracker.
if options.Statistics {
plugins.NewStatistics(red)
}
// If the user selected for verbose debug logs, attach a reduction tracker.
if options.Verbose {
plugins.NewLogs(logger, red)
}
// Run reduction.
red.Reduce()
// Return the final reduced result. // Return the final reduced result.
result := process.GetResult() // For De Bruijn, convert back to lambda for consistent output.
var result string
if options.Interpreter == config.DeBruijnInterpreter {
dbExpr := red.Expression().(debruijn.Expression)
lambdaExpr := convert.DeBruijnToLambda(dbExpr)
result = lambdaExpr.String()
} else {
result = red.Expression().String()
}
err = options.Destination.Write(result) err = options.Destination.Write(result)
cli.HandleError(err) cli.HandleError(err)
} }

View File

@@ -6,9 +6,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.maximhutz.com/max/lambda/internal/config"
"git.maximhutz.com/max/lambda/internal/engine"
"git.maximhutz.com/max/lambda/pkg/convert" "git.maximhutz.com/max/lambda/pkg/convert"
"git.maximhutz.com/max/lambda/pkg/debruijn"
"git.maximhutz.com/max/lambda/pkg/lambda" "git.maximhutz.com/max/lambda/pkg/lambda"
"git.maximhutz.com/max/lambda/pkg/saccharine" "git.maximhutz.com/max/lambda/pkg/saccharine"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -31,24 +30,43 @@ func runSample(samplePath string) (string, error) {
// Compile expression to lambda calculus. // Compile expression to lambda calculus.
compiled := convert.SaccharineToLambda(ast) compiled := convert.SaccharineToLambda(ast)
// Create minimal config for benchmarking. // Create and run the reducer.
cfg := &config.Config{ reducer := lambda.NewNormalOrderReducer(&compiled)
Source: config.StringSource{Data: ""}, reducer.Reduce()
Destination: config.StdoutDestination{},
Profile: "",
Explanation: false,
Statistics: false,
Verbose: false,
}
// Create and run the engine. return reducer.Expression().String() + "\n", nil
process := engine.New(cfg, &compiled)
process.Run()
return lambda.Stringify(compiled) + "\n", nil
} }
// Test that all samples produce expected output. // Helper function to run a single sample through the De Bruijn interpreter.
func runSampleDeBruijn(samplePath string) (string, error) {
// Read the sample file.
input, err := os.ReadFile(samplePath)
if err != nil {
return "", err
}
// Parse code into syntax tree.
ast, err := saccharine.Parse(string(input))
if err != nil {
return "", err
}
// Compile expression to lambda calculus.
compiled := convert.SaccharineToLambda(ast)
// Convert to De Bruijn and run reducer.
dbExpr := convert.LambdaToDeBruijn(compiled)
reducer := debruijn.NewNormalOrderReducer(&dbExpr)
reducer.Reduce()
// Convert back to lambda for output.
result := reducer.Expression().(debruijn.Expression)
lambdaResult := convert.DeBruijnToLambda(result)
return lambdaResult.String() + "\n", nil
}
// Test that all samples produce expected output with lambda interpreter.
func TestSamplesValidity(t *testing.T) { func TestSamplesValidity(t *testing.T) {
// Discover all .test files in the tests directory. // Discover all .test files in the tests directory.
testFiles, err := filepath.Glob("../../tests/*.test") testFiles, err := filepath.Glob("../../tests/*.test")
@@ -77,6 +95,35 @@ func TestSamplesValidity(t *testing.T) {
} }
} }
// Test that all samples produce expected output with De Bruijn interpreter.
func TestSamplesValidityDeBruijn(t *testing.T) {
// Discover all .test files in the tests directory.
testFiles, err := filepath.Glob("../../tests/*.test")
assert.NoError(t, err, "Failed to read tests directory.")
assert.NotEmpty(t, testFiles, "No '*.test' files found in directory.")
for _, testPath := range testFiles {
// Build expected file path.
expectedPath := strings.TrimSuffix(testPath, filepath.Ext(testPath)) + ".expected"
name := strings.TrimSuffix(filepath.Base(testPath), filepath.Ext(testPath))
t.Run(name, func(t *testing.T) {
// Run the sample and capture output.
actual, err := runSampleDeBruijn(testPath)
assert.NoError(t, err, "Failed to run sample.")
// Read expected output.
expectedBytes, err := os.ReadFile(expectedPath)
assert.NoError(t, err, "Failed to read expected output.")
expected := string(expectedBytes)
// Compare outputs.
assert.Equal(t, expected, actual, "Output does not match expected.")
})
}
}
// Benchmark all samples using sub-benchmarks. // Benchmark all samples using sub-benchmarks.
func BenchmarkSamples(b *testing.B) { func BenchmarkSamples(b *testing.B) {
// Discover all .test files in the tests directory. // Discover all .test files in the tests directory.
@@ -95,3 +142,22 @@ func BenchmarkSamples(b *testing.B) {
}) })
} }
} }
// Benchmark all samples using De Bruijn interpreter.
func BenchmarkSamplesDeBruijn(b *testing.B) {
// Discover all .test files in the tests directory.
testFiles, err := filepath.Glob("../../tests/*.test")
assert.NoError(b, err, "Failed to read tests directory.")
assert.NotEmpty(b, testFiles, "No '*.test' files found in directory.")
for _, path := range testFiles {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
b.Run(name, func(b *testing.B) {
for b.Loop() {
_, err := runSampleDeBruijn(path)
assert.NoError(b, err, "Failed to run sample.")
}
})
}
}

View File

@@ -1,6 +1,14 @@
// Package "config" parses ad handles the user settings given to the program. // Package "config" parses ad handles the user settings given to the program.
package config package config
// Interpreter specifies the reduction engine to use.
type Interpreter string
const (
LambdaInterpreter Interpreter = "lambda"
DeBruijnInterpreter Interpreter = "debruijn"
)
// Configuration settings for the program. // Configuration settings for the program.
type Config struct { type Config struct {
Source Source // The source code given to the program. Source Source // The source code given to the program.
@@ -9,5 +17,5 @@ type Config struct {
Explanation bool // Whether or not to print an explanation of the reduction. Explanation bool // Whether or not to print an explanation of the reduction.
Profile string // If not nil, print a CPU profile during execution. Profile string // If not nil, print a CPU profile during execution.
Statistics bool // Whether or not to print statistics. Statistics bool // Whether or not to print statistics.
Interpreter string // The interpreter to use: "lambda" or "debruijn". Interpreter Interpreter // The interpreter engine to use.
} }

View File

@@ -14,9 +14,20 @@ func FromArgs() (*Config, error) {
profile := flag.String("p", "", "CPU profiling. If an output file is defined, the program will profile its execution and dump its results into it.") profile := flag.String("p", "", "CPU profiling. If an output file is defined, the program will profile its execution and dump its results into it.")
file := flag.String("f", "", "File. If set, read source from the specified file.") file := flag.String("f", "", "File. If set, read source from the specified file.")
output := flag.String("o", "", "Output. If set, write result to the specified file. Use '-' for stdout (default).") output := flag.String("o", "", "Output. If set, write result to the specified file. Use '-' for stdout (default).")
interpreter := flag.String("i", "lambda", "Interpreter. Choose 'lambda' or 'debruijn' reduction engine (default: lambda).") interpreter := flag.String("i", "lambda", "Interpreter. The reduction engine to use: 'lambda' or 'debruijn'.")
flag.Parse() flag.Parse()
// Validate interpreter flag.
var interpType Interpreter
switch *interpreter {
case "lambda":
interpType = LambdaInterpreter
case "debruijn":
interpType = DeBruijnInterpreter
default:
return nil, fmt.Errorf("invalid interpreter: %s (must be 'lambda' or 'debruijn')", *interpreter)
}
// Parse source type. // Parse source type.
var source Source var source Source
if *file != "" { if *file != "" {
@@ -46,11 +57,6 @@ func FromArgs() (*Config, error) {
destination = FileDestination{Path: *output} destination = FileDestination{Path: *output}
} }
// Validate interpreter flag.
if *interpreter != "lambda" && *interpreter != "debruijn" {
return nil, fmt.Errorf("invalid interpreter: %s (must be 'lambda' or 'debruijn')", *interpreter)
}
return &Config{ return &Config{
Source: source, Source: source,
Destination: destination, Destination: destination,
@@ -58,6 +64,6 @@ func FromArgs() (*Config, error) {
Explanation: *explanation, Explanation: *explanation,
Profile: *profile, Profile: *profile,
Statistics: *statistics, Statistics: *statistics,
Interpreter: *interpreter, Interpreter: interpType,
}, nil }, nil
} }

View File

@@ -1,36 +0,0 @@
package engine
import (
"git.maximhutz.com/max/lambda/internal/config"
"git.maximhutz.com/max/lambda/pkg/debruijn"
"git.maximhutz.com/max/lambda/pkg/emitter"
)
// A process for reducing one λ-expression using De Bruijn indices.
type DeBruijnEngine struct {
Config *config.Config
Expression *debruijn.Expression
emitter.Emitter
}
// NewDeBruijnEngine creates a new De Bruijn engine.
func NewDeBruijnEngine(config *config.Config, expression interface{}) *DeBruijnEngine {
expr := expression.(*debruijn.Expression)
return &DeBruijnEngine{Config: config, Expression: expr}
}
// Run begins the reduction process.
func (e *DeBruijnEngine) Run() {
e.Emit("start")
debruijn.ReduceAll(e.Expression, func() {
e.Emit("step")
})
e.Emit("end")
}
// GetResult returns the stringified result.
func (e *DeBruijnEngine) GetResult() string {
return debruijn.Stringify(*e.Expression)
}

View File

@@ -1,24 +0,0 @@
// Package "engine" provides an extensible interface for users to interfact with
// λ-calculus.
package engine
import (
"git.maximhutz.com/max/lambda/internal/config"
"git.maximhutz.com/max/lambda/pkg/emitter"
)
// Engine is an interface for reduction engines.
type Engine interface {
Run()
GetResult() string
On(message string, fn func()) *emitter.Observer
Emit(message string)
}
// New creates the appropriate engine based on the config.
func New(cfg *config.Config, input interface{}) Engine {
if cfg.Interpreter == "debruijn" {
return NewDeBruijnEngine(cfg, input)
}
return NewLambdaEngine(cfg, input)
}

View File

@@ -1,36 +0,0 @@
package engine
import (
"git.maximhutz.com/max/lambda/internal/config"
"git.maximhutz.com/max/lambda/pkg/emitter"
"git.maximhutz.com/max/lambda/pkg/lambda"
)
// A process for reducing one λ-expression using named variables.
type LambdaEngine struct {
Config *config.Config
Expression *lambda.Expression
emitter.Emitter
}
// NewLambdaEngine creates a new lambda engine.
func NewLambdaEngine(config *config.Config, expression interface{}) *LambdaEngine {
expr := expression.(*lambda.Expression)
return &LambdaEngine{Config: config, Expression: expr}
}
// Run begins the reduction process.
func (e *LambdaEngine) Run() {
e.Emit("start")
lambda.ReduceAll(e.Expression, func() {
e.Emit("step")
})
e.Emit("end")
}
// GetResult returns the stringified result.
func (e *LambdaEngine) GetResult() string {
return lambda.Stringify(*e.Expression)
}

View File

@@ -1,32 +0,0 @@
// Package "explanation" provides a observer to gather the reasoning during the
// reduction, and present a thorough explanation to the user for each step.
package explanation
import (
"fmt"
"git.maximhutz.com/max/lambda/internal/engine"
"git.maximhutz.com/max/lambda/pkg/lambda"
)
// Track the reductions made by a reduction proess.
type Tracker struct {
process *engine.Engine
}
// Attaches a new explanation tracker to a process.
func Track(process *engine.Engine) *Tracker {
tracker := &Tracker{process: process}
process.On("start", tracker.Start)
process.On("step", tracker.Step)
return tracker
}
func (t *Tracker) Start() {
fmt.Println(lambda.Stringify(*t.process.Expression))
}
func (t *Tracker) Step() {
fmt.Println(" =", lambda.Stringify(*t.process.Expression))
}

23
internal/plugins/debug.go Normal file
View File

@@ -0,0 +1,23 @@
package plugins
import (
"log/slog"
"git.maximhutz.com/max/lambda/pkg/reducer"
)
type Logs struct {
logger *slog.Logger
reducer reducer.Reducer
}
func NewLogs(logger *slog.Logger, r reducer.Reducer) *Logs {
plugin := &Logs{logger, r}
r.On(reducer.StepEvent, plugin.Step)
return plugin
}
func (t *Logs) Step() {
t.logger.Info("reduction", "tree", t.reducer.Expression().String())
}

View File

@@ -0,0 +1,31 @@
// Package "explanation" provides an observer to gather the reasoning during the
// reduction, and present a thorough explanation to the user for each step.
package plugins
import (
"fmt"
"git.maximhutz.com/max/lambda/pkg/reducer"
)
// Track the reductions made by a reduction process.
type Explanation struct {
reducer reducer.Reducer
}
// Attaches a new explanation tracker to a reducer.
func NewExplanation(r reducer.Reducer) *Explanation {
plugin := &Explanation{reducer: r}
r.On(reducer.StartEvent, plugin.Start)
r.On(reducer.StepEvent, plugin.Step)
return plugin
}
func (t *Explanation) Start() {
fmt.Println(t.reducer.Expression().String())
}
func (t *Explanation) Step() {
fmt.Println(" =", t.reducer.Expression().String())
}

View File

@@ -1,28 +1,34 @@
// Package "performance" provides a tracker to observer CPU performance during // Package "performance" provides a tracker to observer CPU performance during
// execution. // execution.
package performance package plugins
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime/pprof" "runtime/pprof"
"git.maximhutz.com/max/lambda/pkg/reducer"
) )
// Observes a reduction process, and publishes a CPU performance profile on // Observes a reduction process, and publishes a CPU performance profile on
// completion. // completion.
type Tracker struct { type Performance struct {
File string File string
filePointer *os.File filePointer *os.File
Error error Error error
} }
// Create a performance tracker that outputs a profile to "file". // Create a performance tracker that outputs a profile to "file".
func Track(file string) *Tracker { func NewPerformance(file string, process reducer.Reducer) *Performance {
return &Tracker{File: file} plugin := &Performance{File: file}
process.On(reducer.StartEvent, plugin.Start)
process.On(reducer.StopEvent, plugin.Stop)
return plugin
} }
// Begin profiling. // Begin profiling.
func (t *Tracker) Start() { func (t *Performance) Start() {
var absPath string var absPath string
absPath, t.Error = filepath.Abs(t.File) absPath, t.Error = filepath.Abs(t.File)
@@ -47,7 +53,7 @@ func (t *Tracker) Start() {
} }
// Stop profiling. // Stop profiling.
func (t *Tracker) End() { func (t *Performance) Stop() {
pprof.StopCPUProfile() pprof.StopCPUProfile()
t.filePointer.Close() t.filePointer.Close()
} }

View File

@@ -0,0 +1,44 @@
package plugins
import (
"fmt"
"os"
"time"
"git.maximhutz.com/max/lambda/internal/statistics"
"git.maximhutz.com/max/lambda/pkg/reducer"
)
// An observer, to track reduction performance.
type Statistics struct {
start time.Time
steps uint64
}
// Create a new reduction performance Statistics.
func NewStatistics(r reducer.Reducer) *Statistics {
plugin := &Statistics{}
r.On(reducer.StartEvent, plugin.Start)
r.On(reducer.StepEvent, plugin.Step)
r.On(reducer.StopEvent, plugin.Stop)
return plugin
}
func (t *Statistics) Start() {
t.start = time.Now()
t.steps = 0
}
func (t *Statistics) Step() {
t.steps++
}
func (t *Statistics) Stop() {
results := statistics.Results{
StepsTaken: t.steps,
TimeElapsed: uint64(time.Since(t.start).Milliseconds()),
}
fmt.Fprint(os.Stderr, results.String())
}

View File

@@ -1,36 +0,0 @@
package statistics
import (
"fmt"
"os"
"time"
)
// An observer, to track reduction performance.
type Tracker struct {
start time.Time
steps uint64
}
// Create a new reduction performance tracker.
func Track() *Tracker {
return &Tracker{}
}
func (t *Tracker) Start() {
t.start = time.Now()
t.steps = 0
}
func (t *Tracker) Step() {
t.steps++
}
func (t *Tracker) End() {
results := Results{
StepsTaken: t.steps,
TimeElapsed: uint64(time.Since(t.start).Milliseconds()),
}
fmt.Fprint(os.Stderr, results.String())
}

View File

@@ -5,58 +5,77 @@ import (
"git.maximhutz.com/max/lambda/pkg/debruijn" "git.maximhutz.com/max/lambda/pkg/debruijn"
"git.maximhutz.com/max/lambda/pkg/lambda" "git.maximhutz.com/max/lambda/pkg/lambda"
"git.maximhutz.com/max/lambda/pkg/set"
) )
// DeBruijnToLambda converts a De Bruijn expression back to named lambda calculus. // DeBruijnToLambda converts a De Bruijn indexed expression back to named lambda calculus.
func DeBruijnToLambda(expr debruijn.Expression) lambda.Expression { func DeBruijnToLambda(expr debruijn.Expression) lambda.Expression {
return deBruijnToLambda(expr, []string{}) return deBruijnToLambdaWithContext(expr, []string{})
} }
func deBruijnToLambda(expr debruijn.Expression, context []string) lambda.Expression { func deBruijnToLambdaWithContext(expr debruijn.Expression, context []string) lambda.Expression {
switch e := expr.(type) { switch e := expr.(type) {
case *debruijn.Variable: case *debruijn.Variable:
if e.Index() >= 0 && e.Index() < len(context) { index := e.Index()
return lambda.NewVariable(context[e.Index()]) if index < len(context) {
// Bound variable: look up name in context.
name := context[len(context)-1-index]
return lambda.NewVariable(name)
} }
// Free variable: use the label if available.
if e.Label() != "" { if e.Label() != "" {
return lambda.NewVariable(e.Label()) return lambda.NewVariable(e.Label())
} }
return lambda.NewVariable(fmt.Sprintf("free_%d", e.Index())) // Generate a name for free variables without labels.
return lambda.NewVariable(fmt.Sprintf("free%d", index))
case *debruijn.Abstraction: case *debruijn.Abstraction:
paramName := generateParamName(context) // Generate a fresh parameter name.
newContext := append([]string{paramName}, context...) used := collectUsedNames(e.Body(), context)
body := deBruijnToLambda(e.Body(), newContext) paramName := generateFreshName(used)
newContext := append(context, paramName)
body := deBruijnToLambdaWithContext(e.Body(), newContext)
return lambda.NewAbstraction(paramName, body) return lambda.NewAbstraction(paramName, body)
case *debruijn.Application: case *debruijn.Application:
abs := deBruijnToLambda(e.Abstraction(), context) abs := deBruijnToLambdaWithContext(e.Abstraction(), context)
arg := deBruijnToLambda(e.Argument(), context) arg := deBruijnToLambdaWithContext(e.Argument(), context)
return lambda.NewApplication(abs, arg) return lambda.NewApplication(abs, arg)
default: default:
return nil panic("unknown expression type")
} }
} }
// generateParamName generates a fresh parameter name that doesn't conflict with context. // collectUsedNames gathers all variable labels used in an expression.
func generateParamName(context []string) string { func collectUsedNames(expr debruijn.Expression, context []string) *set.Set[string] {
base := 'a' used := set.New[string]()
for _, name := range context {
used.Add(name)
}
collectUsedNamesHelper(expr, used)
return used
}
func collectUsedNamesHelper(expr debruijn.Expression, used *set.Set[string]) {
switch e := expr.(type) {
case *debruijn.Variable:
if e.Label() != "" {
used.Add(e.Label())
}
case *debruijn.Abstraction:
collectUsedNamesHelper(e.Body(), used)
case *debruijn.Application:
collectUsedNamesHelper(e.Abstraction(), used)
collectUsedNamesHelper(e.Argument(), used)
}
}
// generateFreshName creates a fresh variable name not in the used set.
func generateFreshName(used *set.Set[string]) string {
for i := 0; ; i++ { for i := 0; ; i++ {
name := string(rune(base + rune(i%26))) name := fmt.Sprintf("_%d", i)
if i >= 26 { if !used.Has(name) {
name = fmt.Sprintf("%s%d", name, i/26)
}
conflict := false
for _, existing := range context {
if existing == name {
conflict = true
break
}
}
if !conflict {
return name return name
} }
} }

View File

@@ -5,39 +5,40 @@ import (
"git.maximhutz.com/max/lambda/pkg/lambda" "git.maximhutz.com/max/lambda/pkg/lambda"
) )
// LambdaToDeBruijn converts a lambda expression to De Bruijn index representation. // LambdaToDeBruijn converts a lambda calculus expression to De Bruijn indexed form.
// The context parameter tracks bound variables from outer abstractions.
func LambdaToDeBruijn(expr lambda.Expression) debruijn.Expression { func LambdaToDeBruijn(expr lambda.Expression) debruijn.Expression {
return lambdaToDeBruijn(expr, []string{}) return lambdaToDeBruijnWithContext(expr, []string{})
} }
func lambdaToDeBruijn(expr lambda.Expression, context []string) debruijn.Expression { func lambdaToDeBruijnWithContext(expr lambda.Expression, context []string) debruijn.Expression {
switch e := expr.(type) { switch e := expr.(type) {
case *lambda.Variable: case *lambda.Variable:
index := findIndex(e.Value(), context) name := e.Value()
return debruijn.NewVariable(index, e.Value()) // Search for the variable in the context (innermost to outermost).
for i := len(context) - 1; i >= 0; i-- {
if context[i] == name {
index := len(context) - 1 - i
return debruijn.NewVariable(index, name)
}
}
// Free variable: use a negative index to mark it.
// We encode free variables with index = len(context) + position.
// For simplicity, we use a large index that won't conflict.
return debruijn.NewVariable(len(context), name)
case *lambda.Abstraction: case *lambda.Abstraction:
newContext := append([]string{e.Parameter()}, context...) // Add the parameter to the context.
body := lambdaToDeBruijn(e.Body(), newContext) newContext := append(context, e.Parameter())
body := lambdaToDeBruijnWithContext(e.Body(), newContext)
return debruijn.NewAbstraction(body) return debruijn.NewAbstraction(body)
case *lambda.Application: case *lambda.Application:
abs := lambdaToDeBruijn(e.Abstraction(), context) abs := lambdaToDeBruijnWithContext(e.Abstraction(), context)
arg := lambdaToDeBruijn(e.Argument(), context) arg := lambdaToDeBruijnWithContext(e.Argument(), context)
return debruijn.NewApplication(abs, arg) return debruijn.NewApplication(abs, arg)
default: default:
return nil panic("unknown expression type")
} }
} }
// 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

@@ -1,12 +0,0 @@
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

@@ -1,75 +1,117 @@
// Package debruijn provides De Bruijn indexed lambda calculus expressions.
// De Bruijn indices eliminate the need for variable names by using numeric
// indices to refer to bound variables, avoiding capture issues during substitution.
package debruijn package debruijn
import "git.maximhutz.com/max/lambda/pkg/expr"
// Expression is the interface for all De Bruijn indexed expression types.
// It embeds the general expr.Expression interface for cross-mode compatibility.
type Expression interface { type Expression interface {
expr.Expression
Accept(Visitor) Accept(Visitor)
} }
/** ------------------------------------------------------------------------- */ /** ------------------------------------------------------------------------- */
// Abstraction represents a lambda abstraction without a named parameter.
// In De Bruijn notation, the parameter is implicit and referenced by index 0
// within the body.
type Abstraction struct { type Abstraction struct {
body Expression body Expression
} }
// Body returns the body of the abstraction.
func (a *Abstraction) Body() Expression { func (a *Abstraction) Body() Expression {
return a.body return a.body
} }
// Accept implements the Visitor pattern.
func (a *Abstraction) Accept(v Visitor) { func (a *Abstraction) Accept(v Visitor) {
v.VisitAbstraction(a) v.VisitAbstraction(a)
} }
// String returns the De Bruijn notation string representation.
func (a *Abstraction) String() string {
return Stringify(a)
}
// NewAbstraction creates a new De Bruijn abstraction with the given body.
func NewAbstraction(body Expression) *Abstraction { func NewAbstraction(body Expression) *Abstraction {
return &Abstraction{body: body} return &Abstraction{body: body}
} }
/** ------------------------------------------------------------------------- */ /** ------------------------------------------------------------------------- */
// Application represents the application of one expression to another.
type Application struct { type Application struct {
abstraction Expression abstraction Expression
argument Expression argument Expression
} }
// Abstraction returns the function expression being applied.
func (a *Application) Abstraction() Expression { func (a *Application) Abstraction() Expression {
return a.abstraction return a.abstraction
} }
// Argument returns the argument expression.
func (a *Application) Argument() Expression { func (a *Application) Argument() Expression {
return a.argument return a.argument
} }
// Accept implements the Visitor pattern.
func (a *Application) Accept(v Visitor) { func (a *Application) Accept(v Visitor) {
v.VisitApplication(a) v.VisitApplication(a)
} }
// String returns the De Bruijn notation string representation.
func (a *Application) String() string {
return Stringify(a)
}
// NewApplication creates a new application expression.
func NewApplication(abstraction Expression, argument Expression) *Application { func NewApplication(abstraction Expression, argument Expression) *Application {
return &Application{abstraction: abstraction, argument: argument} return &Application{abstraction: abstraction, argument: argument}
} }
/** ------------------------------------------------------------------------- */ /** ------------------------------------------------------------------------- */
// Variable represents a De Bruijn indexed variable.
// The index indicates how many binders to skip to find the binding abstraction.
// The label is an optional hint for display purposes.
type Variable struct { type Variable struct {
index int index int
label string label string
} }
// Index returns the De Bruijn index.
func (v *Variable) Index() int { func (v *Variable) Index() int {
return v.index return v.index
} }
// Label returns the optional variable label.
func (v *Variable) Label() string { func (v *Variable) Label() string {
return v.label return v.label
} }
// Accept implements the Visitor pattern.
func (v *Variable) Accept(visitor Visitor) { func (v *Variable) Accept(visitor Visitor) {
visitor.VisitVariable(v) visitor.VisitVariable(v)
} }
// String returns the De Bruijn notation string representation.
func (v *Variable) String() string {
return Stringify(v)
}
// NewVariable creates a new De Bruijn variable with the given index and label.
func NewVariable(index int, label string) *Variable { func NewVariable(index int, label string) *Variable {
return &Variable{index: index, label: label} return &Variable{index: index, label: label}
} }
/** ------------------------------------------------------------------------- */ /** ------------------------------------------------------------------------- */
// Visitor interface for traversing De Bruijn expressions.
type Visitor interface { type Visitor interface {
VisitAbstraction(*Abstraction) VisitAbstraction(*Abstraction)
VisitApplication(*Application) VisitApplication(*Application)

View File

@@ -1,17 +1,21 @@
package debruijn package debruijn
// Iterator provides depth-first traversal of De Bruijn expressions.
type Iterator struct { type Iterator struct {
trace []*Expression trace []*Expression
} }
// NewIterator creates a new iterator starting at the given expression.
func NewIterator(expr *Expression) *Iterator { func NewIterator(expr *Expression) *Iterator {
return &Iterator{[]*Expression{expr}} return &Iterator{[]*Expression{expr}}
} }
// Done returns true when the iterator has finished traversal.
func (i *Iterator) Done() bool { func (i *Iterator) Done() bool {
return len(i.trace) == 0 return len(i.trace) == 0
} }
// Current returns a pointer to the current expression.
func (i *Iterator) Current() *Expression { func (i *Iterator) Current() *Expression {
if i.Done() { if i.Done() {
return nil return nil
@@ -20,6 +24,7 @@ func (i *Iterator) Current() *Expression {
return i.trace[len(i.trace)-1] return i.trace[len(i.trace)-1]
} }
// Parent returns a pointer to the parent expression.
func (i *Iterator) Parent() *Expression { func (i *Iterator) Parent() *Expression {
if len(i.trace) < 2 { if len(i.trace) < 2 {
return nil return nil
@@ -28,6 +33,7 @@ func (i *Iterator) Parent() *Expression {
return i.trace[len(i.trace)-2] return i.trace[len(i.trace)-2]
} }
// Swap replaces the current expression with the given expression.
func (i *Iterator) Swap(with Expression) { func (i *Iterator) Swap(with Expression) {
current := i.Current() current := i.Current()
if current != nil { if current != nil {
@@ -35,6 +41,7 @@ func (i *Iterator) Swap(with Expression) {
} }
} }
// Back moves the iterator back to the parent expression.
func (i *Iterator) Back() bool { func (i *Iterator) Back() bool {
if i.Done() { if i.Done() {
return false return false
@@ -44,6 +51,7 @@ func (i *Iterator) Back() bool {
return true return true
} }
// Next advances the iterator to the next expression in leftmost-outermost order.
func (i *Iterator) Next() { func (i *Iterator) Next() {
switch typed := (*i.Current()).(type) { switch typed := (*i.Current()).(type) {
case *Abstraction: case *Abstraction:

View File

@@ -1,30 +0,0 @@
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()
}
}
}
}

72
pkg/debruijn/reducer.go Normal file
View File

@@ -0,0 +1,72 @@
package debruijn
import (
"git.maximhutz.com/max/lambda/pkg/emitter"
"git.maximhutz.com/max/lambda/pkg/expr"
"git.maximhutz.com/max/lambda/pkg/reducer"
)
// NormalOrderReducer implements normal order (leftmost-outermost) reduction
// for De Bruijn indexed lambda calculus expressions.
type NormalOrderReducer struct {
emitter.BaseEmitter[reducer.Event]
expression *Expression
}
// NewNormalOrderReducer creates a new normal order reducer.
func NewNormalOrderReducer(expression *Expression) *NormalOrderReducer {
return &NormalOrderReducer{
BaseEmitter: *emitter.New[reducer.Event](),
expression: expression,
}
}
// Expression returns the current expression state.
func (r *NormalOrderReducer) Expression() expr.Expression {
return *r.expression
}
// isViable checks if an expression is a redex (reducible expression).
// A redex is an application of an abstraction to an argument.
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
}
}
// betaReduce performs a single beta reduction step.
// Given (\. body) arg, it substitutes arg for index 0 in body,
// then shifts the result down to account for the removed abstraction.
func betaReduce(fn *Abstraction, arg Expression) Expression {
// Substitute arg for variable 0 in the body.
substituted := Substitute(fn.body, 0, Shift(arg, 1, 0))
// Shift down to account for the removed abstraction.
return Shift(substituted, -1, 0)
}
// Reduce performs normal order reduction on a De Bruijn expression.
func (r *NormalOrderReducer) Reduce() {
r.Emit(reducer.StartEvent)
it := NewIterator(r.expression)
for !it.Done() {
if fn, arg, ok := isViable(it.Current()); !ok {
it.Next()
} else {
it.Swap(betaReduce(fn, arg))
r.Emit(reducer.StepEvent)
if _, _, ok := isViable(it.Parent()); ok {
it.Back()
}
}
}
r.Emit(reducer.StopEvent)
}

32
pkg/debruijn/shift.go Normal file
View File

@@ -0,0 +1,32 @@
package debruijn
// Shift increments all free variable indices in an expression by the given amount.
// A variable is free if its index is >= the cutoff (depth of nested abstractions).
// This is necessary when substituting an expression into a different binding context.
func Shift(expr Expression, amount int, cutoff int) Expression {
switch e := expr.(type) {
case *Variable:
if e.index >= cutoff {
return NewVariable(e.index+amount, e.label)
}
return e
case *Abstraction:
newBody := Shift(e.body, amount, cutoff+1)
if newBody == e.body {
return e
}
return NewAbstraction(newBody)
case *Application:
newAbs := Shift(e.abstraction, amount, cutoff)
newArg := Shift(e.argument, amount, cutoff)
if newAbs == e.abstraction && newArg == e.argument {
return e
}
return NewApplication(newAbs, newArg)
default:
return expr
}
}

View File

@@ -1,7 +1,7 @@
package debruijn package debruijn
import ( import (
"fmt" "strconv"
"strings" "strings"
) )
@@ -10,11 +10,7 @@ type stringifyVisitor struct {
} }
func (v *stringifyVisitor) VisitVariable(a *Variable) { func (v *stringifyVisitor) VisitVariable(a *Variable) {
if a.label != "" { v.builder.WriteString(strconv.Itoa(a.index))
v.builder.WriteString(a.label)
} else {
v.builder.WriteString(fmt.Sprintf("%d", a.index))
}
} }
func (v *stringifyVisitor) VisitAbstraction(f *Abstraction) { func (v *stringifyVisitor) VisitAbstraction(f *Abstraction) {
@@ -31,6 +27,7 @@ func (v *stringifyVisitor) VisitApplication(c *Application) {
v.builder.WriteRune(')') v.builder.WriteRune(')')
} }
// Stringify converts a De Bruijn expression to its string representation.
func Stringify(e Expression) string { func Stringify(e Expression) string {
b := &stringifyVisitor{builder: strings.Builder{}} b := &stringifyVisitor{builder: strings.Builder{}}
e.Accept(b) e.Accept(b)

View File

@@ -1,62 +1,28 @@
package debruijn package debruijn
// Shift increments all free variable indices by delta when crossing depth abstractions. // Substitute replaces the variable at the given index with the replacement expression.
func Shift(expr Expression, delta int, depth int) Expression { // The replacement is shifted appropriately as we descend into nested abstractions.
func Substitute(expr Expression, index int, replacement Expression) Expression {
switch e := expr.(type) { switch e := expr.(type) {
case *Variable: case *Variable:
if e.index >= depth { if e.index == index {
return NewVariable(e.index+delta, e.label) return replacement
} }
return e return e
case *Abstraction: case *Abstraction:
newBody := Shift(e.body, delta, depth+1) // When entering an abstraction, increment the target index and shift the
// replacement to account for the new binding context.
shiftedReplacement := Shift(replacement, 1, 0)
newBody := Substitute(e.body, index+1, shiftedReplacement)
if newBody == e.body { if newBody == e.body {
return e return e
} }
return NewAbstraction(newBody) return NewAbstraction(newBody)
case *Application: case *Application:
newAbs := Shift(e.abstraction, delta, depth) newAbs := Substitute(e.abstraction, index, replacement)
newArg := Shift(e.argument, delta, depth) newArg := Substitute(e.argument, index, replacement)
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 { if newAbs == e.abstraction && newArg == e.argument {
return e return e
} }

View File

@@ -2,53 +2,45 @@ package emitter
import "git.maximhutz.com/max/lambda/pkg/set" import "git.maximhutz.com/max/lambda/pkg/set"
type Observer struct { type Emitter[E comparable] interface {
fn func() On(E, func()) Listener[E]
message string Off(Listener[E])
emitter *Emitter Emit(E)
} }
type Emitter struct { type BaseEmitter[E comparable] struct {
listeners map[string]*set.Set[*Observer] listeners map[E]*set.Set[Listener[E]]
} }
func Ignore[T any](fn func()) func(T) { func (e *BaseEmitter[E]) On(kind E, fn func()) Listener[E] {
return func(T) { fn() } if e.listeners[kind] == nil {
e.listeners[kind] = set.New[Listener[E]]()
}
listener := &BaseListener[E]{kind, fn}
e.listeners[kind].Add(listener)
return listener
} }
func (e *Emitter) On(message string, fn func()) *Observer { func (e *BaseEmitter[E]) Off(listener Listener[E]) {
observer := &Observer{ kind := listener.Kind()
fn: fn, if e.listeners[kind] != nil {
message: message, e.listeners[kind].Remove(listener)
emitter: e, }
} }
if e.listeners == nil { func (e *BaseEmitter[E]) Emit(event E) {
e.listeners = map[string]*set.Set[*Observer]{} if e.listeners[event] == nil {
} e.listeners[event] = set.New[Listener[E]]()
}
if e.listeners[message] == nil {
e.listeners[message] = set.New[*Observer]() for listener := range e.listeners[event].Items() {
} listener.Run()
}
e.listeners[message].Add(observer) }
return observer
} func New[E comparable]() *BaseEmitter[E] {
return &BaseEmitter[E]{
func (o *Observer) Off() { listeners: map[E]*set.Set[Listener[E]]{},
if o.emitter.listeners[o.message] == nil {
return
}
o.emitter.listeners[o.message].Remove(o)
}
func (e *Emitter) Emit(message string) {
if e.listeners[message] == nil {
return
}
for listener := range *e.listeners[message] {
listener.fn()
} }
} }

19
pkg/emitter/listener.go Normal file
View File

@@ -0,0 +1,19 @@
package emitter
type Listener[E comparable] interface {
Kind() E
Run()
}
type BaseListener[E comparable] struct {
kind E
fn func()
}
func (l BaseListener[E]) Kind() E {
return l.kind
}
func (l BaseListener[E]) Run() {
l.fn()
}

11
pkg/expr/expr.go Normal file
View File

@@ -0,0 +1,11 @@
// Package expr provides the abstract Expression interface for all evaluatable
// expression types in the lambda interpreter.
package expr
// Expression is the base interface for all evaluatable expression types.
// Different evaluation modes (lambda calculus, SKI combinators, typed lambda
// calculus, etc.) implement this interface with their own concrete types.
type Expression interface {
// String returns a human-readable representation of the expression.
String() string
}

View File

@@ -1,6 +1,11 @@
package lambda package lambda
import "git.maximhutz.com/max/lambda/pkg/expr"
// Expression is the interface for all lambda calculus expression types.
// It embeds the general expr.Expression interface for cross-mode compatibility.
type Expression interface { type Expression interface {
expr.Expression
Accept(Visitor) Accept(Visitor)
} }
@@ -23,6 +28,10 @@ func (a *Abstraction) Accept(v Visitor) {
v.VisitAbstraction(a) v.VisitAbstraction(a)
} }
func (a *Abstraction) String() string {
return Stringify(a)
}
func NewAbstraction(parameter string, body Expression) *Abstraction { func NewAbstraction(parameter string, body Expression) *Abstraction {
return &Abstraction{parameter: parameter, body: body} return &Abstraction{parameter: parameter, body: body}
} }
@@ -46,6 +55,10 @@ func (a *Application) Accept(v Visitor) {
v.VisitApplication(a) v.VisitApplication(a)
} }
func (a *Application) String() string {
return Stringify(a)
}
func NewApplication(abstraction Expression, argument Expression) *Application { func NewApplication(abstraction Expression, argument Expression) *Application {
return &Application{abstraction: abstraction, argument: argument} return &Application{abstraction: abstraction, argument: argument}
} }
@@ -64,6 +77,10 @@ func (v *Variable) Accept(visitor Visitor) {
visitor.VisitVariable(v) visitor.VisitVariable(v)
} }
func (v *Variable) String() string {
return Stringify(v)
}
func NewVariable(name string) *Variable { func NewVariable(name string) *Variable {
return &Variable{value: name} return &Variable{value: name}
} }

View File

@@ -1,30 +0,0 @@
package lambda
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, fn.parameter, arg))
step()
if _, _, ok := IsViable(it.Parent()); ok {
it.Back()
}
}
}
}

61
pkg/lambda/reducer.go Normal file
View File

@@ -0,0 +1,61 @@
package lambda
import (
"git.maximhutz.com/max/lambda/pkg/emitter"
"git.maximhutz.com/max/lambda/pkg/expr"
"git.maximhutz.com/max/lambda/pkg/reducer"
)
// NormalOrderReducer implements normal order (leftmost-outermost) reduction
// for lambda calculus expressions.
type NormalOrderReducer struct {
emitter.BaseEmitter[reducer.Event]
expression *Expression
}
// NewNormalOrderReducer creates a new normal order reducer.
func NewNormalOrderReducer(expression *Expression) *NormalOrderReducer {
return &NormalOrderReducer{
BaseEmitter: *emitter.New[reducer.Event](),
expression: expression,
}
}
// Expression returns the current expression state.
func (r *NormalOrderReducer) Expression() expr.Expression {
return *r.expression
}
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
}
}
// Reduce performs normal order reduction on a lambda expression.
// The expression must be a lambda.Expression; other types are returned unchanged.
func (r *NormalOrderReducer) Reduce() {
r.Emit(reducer.StartEvent)
it := NewIterator(r.expression)
for !it.Done() {
if fn, arg, ok := isViable(it.Current()); !ok {
it.Next()
} else {
it.Swap(Substitute(fn.body, fn.parameter, arg))
r.Emit(reducer.StepEvent)
if _, _, ok := isViable(it.Parent()); ok {
it.Back()
}
}
}
r.Emit(reducer.StopEvent)
}

13
pkg/reducer/events.go Normal file
View File

@@ -0,0 +1,13 @@
package reducer
// Event represents lifecycle events during reduction.
type Event int
const (
// StartEvent is emitted before reduction begins.
StartEvent Event = iota
// StepEvent is emitted after each reduction step.
StepEvent
// StopEvent is emitted after reduction completes.
StopEvent
)

27
pkg/reducer/reducer.go Normal file
View File

@@ -0,0 +1,27 @@
// Package reducer provides the abstract Reducer interface for all expression
// reduction strategies.
package reducer
import (
"git.maximhutz.com/max/lambda/pkg/emitter"
"git.maximhutz.com/max/lambda/pkg/expr"
)
// Reducer defines the interface for expression reduction strategies.
// Different evaluation modes (normal order, applicative order, SKI combinators,
// etc.) implement this interface with their own reduction logic.
//
// Reducers also implement the Emitter interface to allow plugins to observe
// reduction lifecycle events (Start, Step, Stop).
type Reducer interface {
emitter.Emitter[Event]
// Reduce performs all reduction steps on the expression.
// Emits StartEvent before reduction, StepEvent after each step, and
// StopEvent after completion.
// Returns the final reduced expression.
Reduce()
// Expression returns the current expression state.
Expression() expr.Expression
}

View File

@@ -1,5 +1,7 @@
package set package set
import "iter"
type Set[T comparable] map[T]bool type Set[T comparable] map[T]bool
func (s *Set[T]) Add(items ...T) { func (s *Set[T]) Add(items ...T) {
@@ -34,6 +36,16 @@ func (s Set[T]) ToList() []T {
return list return list
} }
func (s Set[T]) Items() iter.Seq[T] {
return func(yield func(T) bool) {
for item := range s {
if !yield(item) {
return
}
}
}
}
func New[T comparable](items ...T) *Set[T] { func New[T comparable](items ...T) *Set[T] {
result := &Set[T]{} result := &Set[T]{}