In previous explorations, I described how the syntax of applicative calculi can be organized by magmas and catamorphisms. We saw that computation unfolds as a coalgebraic observation of states. We then stripped this down to a minimal, foundational perspective: a calculus is fundamentally a formal language, a sublanguage of reduced terms, and a partial reduction map.
But how do we formally translate between completely different computational universes—like moving from the single-combinator \(\iota\)-calculus to the full untyped \(\lambda\)-calculus—while preserving their mathematical structure? In this post, I will walk through a Scala framework to model the category of applicative calculi and the homomorphisms that bridge them.
To formalize our calculi, we start with the core algebraic definition.
The defining axiom for this map is its strict compatibility with application: reducing either input before forming an application must not change the reduced result. In other words, for any terms \(a, b \in L\), the partial expressions \(\beta(ab)\), \(\beta(\beta(a)b)\), \(\beta(a\beta(b))\), and \(\beta(\beta(a)\beta(b))\) must all be strongly Kleene equal. This referential transparency ensures that our evaluation logic is structurally sound.
Let us encode this universal evaluation engine in Scala. By defining
a generic Calculus trait, we decouple the evaluation loop
from the specific reduction rules of any given language.
trait Calculus:
type Expr
def app(left: Expr, right: Expr): Expr
extension (left: Expr)
def apply(right: Expr): Expr = app(left, right)
def reduceStep(expr: Expr): Option[Expr]
import scala.annotation.tailrec
final def normalize(expr: Expr, depthLimit: Option[Int] = None): Option[Expr] =
@tailrec def loop(current: Expr, currentDepth: Int): Option[Expr] =
if depthLimit.exists(limit => currentDepth > limit) then None
else
reduceStep(current) match
case Some(next) => loop(next, currentDepth + 1)
case None => Some(current)
Before we evaluate, we need syntax. The raw syntax of \(\lambda\)-terms before any notion of reduction can be modeled by the free magma freely generated by the set of atomic terms, and within this magmatic structure, the binary operation corresponds directly to application.
For combinatory logic, this is perfectly literal. SKI terms are the
free magma on the three generators \(\{S, K,
I\}\), and iota terms are the free magma on the single generator
\(\{\iota\}\). By building off a
generic FreeMagma, we can define both calculi simply by
providing their alphabets.
Let us start with the free magma (binary trees):
enum FreeMagma[A]:
case Leaf(value: A)
case App(left: FreeMagma[A], right: FreeMagma[A])Next, the SKI-calculus:
object SKICalculus extends Calculus:
enum SKI:
case S, K, I
type Expr = FreeMagma[SKI]
val S: Expr = FreeMagma.Leaf(SKI.S)
val K: Expr = FreeMagma.Leaf(SKI.K)
val I: Expr = FreeMagma.Leaf(SKI.I)
def app(left: Expr, right: Expr): Expr = FreeMagma.App(left, right)
def reduceStep(expr: Expr): Option[Expr] = expr match
case FreeMagma.App(FreeMagma.Leaf(SKI.I), x) =>
Some(x)
case FreeMagma.App(FreeMagma.App(FreeMagma.Leaf(SKI.K), x), y) =>
Some(x)
case FreeMagma.App(FreeMagma.App(FreeMagma.App(FreeMagma.Leaf(SKI.S), x), y), z) =>
Some(app(app(x, z), app(y, z)))
case FreeMagma.App(left, right) =>
reduceStep(left).map(app(_, right)).orElse(reduceStep(right).map(app(left, _)))
case _ => None
And the \(\iota\)-calculus
object IotaCalculus extends Calculus:
enum IotaGen:
case Iota
type Expr = FreeMagma[IotaGen]
val i: Expr = FreeMagma.Leaf(IotaGen.Iota)
def app(left: Expr, right: Expr): Expr = FreeMagma.App(left, right)
def reduceStep(expr: Expr): Option[Expr] = None
However, the untyped \(\lambda\)-calculus is not simply a free
magma; its abstract syntax tree consists of variables, abstractions, and
applications. Because our Calculus trait leaves the
Expr type abstract, we can seamlessly define
LambdaCalculus with its own native grammar alongside our
magmas.
object LambdaCalculus extends Calculus:
enum LambdaExpr:
case Var(name: String)
case Abs(param: String, body: LambdaExpr)
case App(func: LambdaExpr, arg: LambdaExpr)
type Expr = LambdaExpr
def app(func: Expr, arg: Expr): Expr = LambdaExpr.App(func, arg)
private def freeVars(expr: Expr): Set[String] = expr match
case LambdaExpr.Var(name) => Set(name)
case LambdaExpr.Abs(param, body) => freeVars(body) - param
case LambdaExpr.App(func, arg) => freeVars(func) ++ freeVars(arg)
private def substitute(expr: Expr, target: String, replacement: Expr): Expr = expr match
case LambdaExpr.Var(name) =>
if name == target then replacement else expr
case LambdaExpr.App(func, arg) =>
app(substitute(func, target, replacement), substitute(arg, target, replacement))
case LambdaExpr.Abs(param, body) =>
if param == target then
// The bound variable shadows the target; do not substitute inside
expr
else if freeVars(replacement).contains(param) && freeVars(body).contains(target) then
// Name collision: α-rename the bound variable to a fresh name
val freshParam = param + "'"
val renamedBody = substitute(body, param, LambdaExpr.Var(freshParam))
LambdaExpr.Abs(freshParam, substitute(renamedBody, target, replacement))
else
// Safe to substitute directly
LambdaExpr.Abs(param, substitute(body, target, replacement))
def reduceStep(expr: Expr): Option[Expr] = expr match
// Beta Reduction
case LambdaExpr.App(LambdaExpr.Abs(param, body), arg) =>
Some(substitute(body, param, arg))
// Evaluate the function position first
case LambdaExpr.App(func, arg) =>
reduceStep(func) match
case Some(reducedFunc) => Some(app(reducedFunc, arg))
case None => reduceStep(arg).map(app(func, _))
// Evaluate inside abstractions
case LambdaExpr.Abs(param, body) =>
reduceStep(body).map(LambdaExpr.Abs(param, _))
case _ => NoneIf we view calculi as objects in a category, we need morphisms to connect them. A morphism of behavioral applicative calculi must preserve the chosen reduction and the reduced applicative product up to behavioral equivalence. In simpler terms, a valid morphism between calculi must respect the reduced applicative structure.
We can define a generic Homomorphism trait that acts as
a structural translation between any two calculi. By mapping the
generators (or AST nodes) of the source calculus to valid expressions in
the target calculus, we preserve the algebraic operations across the
boundary.
trait Homomorphism[S <: Calculus, T <: Calculus](val source: S, val target: T):
def apply(expr: source.Expr): target.ExprMapping our pure IotaCalculus into
SKICalculus space is a simple substitution of the
generator, as \(\iota\) maps cleanly to
a predefined SKI topological tree.
object IotaToSKI extends Homomorphism(IotaCalculus, SKICalculus):
import SKICalculus.{S, K, I}
def apply(expr: source.Expr): target.Expr = expr match
case FreeMagma.Leaf(IotaCalculus.IotaGen.Iota) =>
// The mathematical translation of ι into SKI: S (S I (K S)) (K K)
S(S(I)(K(S)))(K(K))
case FreeMagma.App(left, right) =>
SKICalculus.app(this.apply(left), this.apply(right))Translating the \(\lambda\)-calculus to SKI is more complex. We use bracket abstraction, which translates lambda terms to SKI terms. Because \(\lambda\)-terms possess bound variables and pure SKI terms do not, we must algorithmically eliminate the variable bindings bottom-up. The bracket-abstraction theorem gives the exact comparison and equivalence between the two calculi.
To implement this safely in Scala, we use a private intermediate AST
(SKIComp) that temporarily holds variables while the
bracket abstraction eliminates them, before extracting the pure,
variable-free SKI tree.
object LambdaToSKI extends Homomorphism(LambdaCalculus, SKICalculus):
import LambdaCalculus.LambdaExpr
import SKICalculus.{S, K, I, app}
// 1. Intermediate AST to hold variables during bracket abstraction
private enum SKIComp:
case Comb(expr: SKICalculus.Expr)
case Var(name: String)
case App(left: SKIComp, right: SKIComp)
private def freeVars(expr: SKIComp): Set[String] = expr match
case SKIComp.Comb(_) => Set.empty
case SKIComp.Var(name) => Set(name)
case SKIComp.App(l, r) => freeVars(l) ++ freeVars(r)
// 2. The Bracket Abstraction algorithm
private def abstractVar(param: String, body: SKIComp): SKIComp =
if !freeVars(body).contains(param) then
// If x is not free in M, return K M
SKIComp.App(SKIComp.Comb(K), body)
else body match
case SKIComp.Var(name) if name == param =>
// If M is just x, return I
SKIComp.Comb(I)
case SKIComp.App(l, r) =>
// If M is (U V), return S (abstract U) (abstract V)
SKIComp.App(SKIComp.App(SKIComp.Comb(S), abstractVar(param, l)), abstractVar(param, r))
case _ =>
throw new IllegalStateException("Invalid state in bracket abstraction")
// 3. Compile Lambda AST to intermediate AST, eliminating lambdas bottom-up
private def compile(expr: source.Expr): SKIComp = expr match
case LambdaExpr.Var(name) => SKIComp.Var(name)
case LambdaExpr.App(func, arg) => SKIComp.App(compile(func), compile(arg))
case LambdaExpr.Abs(param, body) => abstractVar(param, compile(body))
// 4. Extract pure SKI tree (fails safely if open terms are provided)
private def extract(expr: SKIComp): target.Expr = expr match
case SKIComp.Comb(e) => e
case SKIComp.App(l, r) => app(extract(l), extract(r))
case SKIComp.Var(name) => throw new IllegalArgumentException(s"Unbound free variable: $name")
def apply(expr: source.Expr): target.Expr = extract(compile(expr))Why define these strictly typed homomorphisms? Raw syntactic translation is an incomplete account of computation. We seek to formally identify systems that exhibit identical computational behavior, even when their underlying geometric representations differ. Analogous to the localization of weak equivalences in abstract homotopy theory, we define a behavioral weak equivalence as a behavior-respecting morphism that induces a bijection on behavioral quotients. In the Gabriel-Zisman localization of this category, these behavioral weak equivalences are inverted, becoming true isomorphisms.
Within this localized setting, the standard equivalences among classical applicative and concatenative presentations are expressed as precise categorical weak equivalences. Specifically, bracket abstraction, lambda interpretation, and the iota encoding generate inverse behavior classes in the behavioral quotient. Consequently, presentations of the untyped \(\lambda\)-calculus, SKI, and the \(\iota\)-calculus become isomorphic in the behavioral localization of behaviorally diagonally closed applicative calculi.
Through this framework, type safety ceases to be an external compilation step; it becomes a physical requirement of the magmoid structure itself. By decoupling language, semantics, and interpretation, we establish a mathematically rigorous and type-safe environment suitable for exploring the foundational topology of computation.
To observe this algebraic structure in motion, we can evaluate terms across our three distinct calculi. The Scala implementation executes the formal reductions step-by-step until normal forms are reached, or translates them across homomorphic boundaries.
In the SKI calculus, we can test the standard identity reduction. The term \(S(K)(K)(I)\) structurally routes the combinators to ultimately collapse into the pure identity combinator \(I\). Applying our normalizer perfectly tracks the reduction rules until the free magma tree resolves to the final state.
import SKICalculus.{S, K, I, apply}
val skiExpr = S(K)(K)(I)
val skiResult = SKICalculus.normalize(skiExpr, Some(100))
println(s"Result: ${skiResult.get}")Result: Leaf(I)
The pure \(\iota\)-calculus is
evaluated homomorphically. Since pure \(\iota\) has no stable single-step
reductions without diverging into infinite macro-expansions, we bypass
the issue by utilizing our localized weak equivalences. We construct the
identity term \(\iota(\iota)\), map it
through the IotaToSKI homomorphism into the SKI evaluation
engine, reduce it safely there, and seamlessly map the normal form back
to \(\iota\). The massive resulting
\(\iota\)-tree is the strictly closed
mathematical result of the computation.
import IotaCalculus.i
val iotaExpr = i(i)
val mappedToSKI = IotaToSKI(iotaExpr)
val evaluatedSKI = SKICalculus.normalize(mappedToSKI, Some(100))
val resultIota = evaluatedSKI.map(SKIToIota.apply)
println(s"Result: ${resultIota.get}")Result: App(App(App(Leaf(Iota),App(Leaf(Iota),App(Leaf(Iota),App(Leaf(Iota),Leaf(Iota))))),App(Leaf(Iota),App(Leaf(Iota),App(Leaf(Iota),Leaf(Iota))))),App(App(Leaf(Iota),App(Leaf(Iota),App(Leaf(Iota),Leaf(Iota)))),App(Leaf(Iota),App(Leaf(Iota),App(Leaf(Iota),Leaf(Iota))))))
Here is the rewritten section. I have added a narrative that frames
these examples as a demonstration of the framework’s power: first
verifying native reduction within the \(\lambda\)-calculus, and then proving that
our LambdaToSKI homomorphism preserves computational
behavior by lifting the term into the SKI magma.
To observe this algebraic structure in motion, we can evaluate terms across our distinct calculi. The Scala implementation executes the formal reductions step-by-step until normal forms are reached, or translates them across homomorphic boundaries to verify structural equivalence.
To see how the structural translation handles variable binding, we
employ Church Booleans. These encodings allow us to represent logical
values as higher-order functions within the untyped \(\lambda\)-calculus. We define the logical
NOT operator and apply it to TRUE. The engine
applies \(\beta\)-reduction with
capture-avoiding substitution to traverse the abstract syntax tree and
reduce the expression to its canonical form, demonstrating the soundness
of our \(\lambda\)-calculus
normalization rules[cite: 5].
import LambdaCalculus.*
val True = lam("x", lam("y", v("x")))
val False = lam("x", lam("y", v("y")))
val Not = lam("p", v("p")(False)(True))
val lambdaExpr = Not(True)
val lambdaResult = LambdaCalculus.normalize(lambdaExpr, Some(100))
println(s"Result: ${lambdaResult.get}")
println(s"Matches Church FALSE? ${lambdaResult.get == False}")Result: Abs(x,Abs(y,Var(y)))
Matches Church FALSE? true
The true strength of our categorical framework lies in its ability to
translate across different computational universes. The
LambdaToSKI homomorphism uses the bracket-abstraction
algorithm to compile variable-dependent \(\lambda\)-terms into pure, variable-free
combinatory logic trees.
By translating lambdaExpr into the SKI magma and
normalizing it there, we verify that our translation is a behavioral
weak equivalence. Because both the \(\lambda\)-calculus and SKI are
combinatorially complete and behaviorally diagonally closed, the result
of the converted reduction must be behaviorally equivalent to our
initial native result.
val lambdaConverted = LambdaToSKI(lambdaExpr)
val resultConverted = SKICalculus.normalize(lambdaConverted)
println(s"Converted AST: ${lambdaConverted}")
println(s"Normalization successful: ${resultConverted.isDefined}")Converted: App(App(App(Leaf(S),App(App(Leaf(S),Leaf(I)),App(Leaf(K),App(Leaf(K),Leaf(I)))),App(Leaf(K),App(App(Leaf(S),App(Leaf(K),Leaf(K))),Leaf(I)))),App(App(Leaf(S),App(Leaf(K),Leaf(K))),Leaf(I)))
Normalization successful: true
These examples confirm the theoretical core of the framework: different syntax, same computational behavior, mapped through the prism of structural homomorphisms.