Prepromorphism — Apply Natural Transformation at Each Step
Functional Programming
Tutorial
The Problem
A prepromorphism applies a natural transformation (a functor-to-functor mapping) to each layer of a structure before folding it. This enables optimizations or normalizations that happen during the fold: simplifying expression nodes before evaluating them, or applying a "compress" step before accumulating. The natural transformation must be a functor endomorphism: F<A> -> F<A> (same functor, same element type).
🎯 Learning Outcomes
ana)Code Example
fn prepro<A>(
nat: &dyn Fn(ExprF<Fix>) -> ExprF<Fix>,
alg: &dyn Fn(ExprF<A>) -> A,
Fix(f): &Fix,
) -> A {
alg(f.map_ref(|child| {
let transformed = Fix(Box::new(nat(child.0.as_ref().clone())));
prepro(nat, alg, &transformed)
}))
}Key Differences
prepro applies nat before recursing into children — the transformation sees the original children as Fix values, not folded results.nat: F<Fix<F>> -> F<Fix<F>> maps one layer to another layer of the same type; this is the "natural transformation" in category theory.nat is applied at every level during the fold, simplifications cascade: simplifying the outer level exposes inner simplifications.ana (unfolding); prepro is to cata as postpro is to ana.OCaml Approach
OCaml's prepromorphism:
let rec prepro nat alg (Fix ef) =
alg (map_expr_f (prepro nat alg) (nat ef))
nat ef applies the natural transformation first, then map_expr_f (prepro nat alg) recurses on the (possibly simplified) children. The simplification nat can arbitrarily rewrite the top layer of the expression at each recursive step.
Full Source
#![allow(clippy::all)]
// Example 225: Prepromorphism — Apply Natural Transformation at Each Step of Cata
// prepro: like cata, but applies a nat transform to each layer before recursing
#[derive(Debug)]
enum ExprF<A> {
LitF(i64),
AddF(A, A),
MulF(A, A),
NegF(A),
}
impl<A> ExprF<A> {
fn map<B>(self, f: impl Fn(A) -> B) -> ExprF<B> {
match self {
ExprF::LitF(n) => ExprF::LitF(n),
ExprF::AddF(a, b) => ExprF::AddF(f(a), f(b)),
ExprF::MulF(a, b) => ExprF::MulF(f(a), f(b)),
ExprF::NegF(a) => ExprF::NegF(f(a)),
}
}
fn map_ref<B>(&self, f: impl Fn(&A) -> B) -> ExprF<B> {
match self {
ExprF::LitF(n) => ExprF::LitF(*n),
ExprF::AddF(a, b) => ExprF::AddF(f(a), f(b)),
ExprF::MulF(a, b) => ExprF::MulF(f(a), f(b)),
ExprF::NegF(a) => ExprF::NegF(f(a)),
}
}
}
#[derive(Debug, Clone)]
struct Fix(Box<ExprF<Fix>>);
fn cata<A>(alg: &dyn Fn(ExprF<A>) -> A, Fix(f): &Fix) -> A {
alg(f.map_ref(|child| cata(alg, child)))
}
// prepro: transform each child's layer before recursing
fn prepro<A>(
nat: &dyn Fn(ExprF<Fix>) -> ExprF<Fix>,
alg: &dyn Fn(ExprF<A>) -> A,
Fix(f): &Fix,
) -> A {
alg(f.map_ref(|child| {
// Apply natural transformation to child's layer, then recurse
let transformed = Fix(Box::new(nat(child.0.as_ref().clone())));
prepro(nat, alg, &transformed)
}))
}
impl<A: Clone> Clone for ExprF<A> {
fn clone(&self) -> Self {
self.map_ref(|a| a.clone())
}
}
fn eval_alg(e: ExprF<i64>) -> i64 {
match e {
ExprF::LitF(n) => n,
ExprF::AddF(a, b) => a + b,
ExprF::MulF(a, b) => a * b,
ExprF::NegF(a) => -a,
}
}
// Approach 1: Replace Mul with Add
fn mul_to_add(e: ExprF<Fix>) -> ExprF<Fix> {
match e {
ExprF::MulF(a, b) => ExprF::AddF(a, b),
other => other,
}
}
// Approach 2: Double all literals
fn double_lits(e: ExprF<Fix>) -> ExprF<Fix> {
match e {
ExprF::LitF(n) => ExprF::LitF(n * 2),
other => other,
}
}
// Approach 3: Remove negations
fn remove_neg(e: ExprF<Fix>) -> ExprF<Fix> {
match e {
ExprF::NegF(a) => a.0.as_ref().clone(),
other => other,
}
}
fn identity_nat(e: ExprF<Fix>) -> ExprF<Fix> {
e
}
fn lit(n: i64) -> Fix {
Fix(Box::new(ExprF::LitF(n)))
}
fn add(a: Fix, b: Fix) -> Fix {
Fix(Box::new(ExprF::AddF(a, b)))
}
fn mul(a: Fix, b: Fix) -> Fix {
Fix(Box::new(ExprF::MulF(a, b)))
}
fn neg(a: Fix) -> Fix {
Fix(Box::new(ExprF::NegF(a)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identity_is_cata() {
let e = add(lit(1), mul(lit(2), lit(3)));
assert_eq!(cata(&eval_alg, &e), prepro(&identity_nat, &eval_alg, &e));
}
#[test]
fn test_mul_to_add() {
let e = mul(mul(lit(2), lit(3)), lit(4));
// prepro applies mul→add at each layer before recursing
// Result differs from simple cata due to repeated transformation
assert_eq!(prepro(&mul_to_add, &eval_alg, &e), 20);
}
#[test]
fn test_double_nested() {
let e = add(add(lit(1), lit(1)), lit(1));
// Outer add: children get doubled
// Inner add(1,1): its children (lit 1) get doubled to (lit 2) → 2+2=4
// But inner add itself was already doubled... depth matters
let result = prepro(&double_lits, &eval_alg, &e);
assert!(result > 3); // exact value depends on depth
}
#[test]
fn test_remove_double_neg() {
assert_eq!(prepro(&remove_neg, &eval_alg, &neg(neg(neg(lit(7))))), 7);
}
}
✓ Tests
Rust test suite
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identity_is_cata() {
let e = add(lit(1), mul(lit(2), lit(3)));
assert_eq!(cata(&eval_alg, &e), prepro(&identity_nat, &eval_alg, &e));
}
#[test]
fn test_mul_to_add() {
let e = mul(mul(lit(2), lit(3)), lit(4));
// prepro applies mul→add at each layer before recursing
// Result differs from simple cata due to repeated transformation
assert_eq!(prepro(&mul_to_add, &eval_alg, &e), 20);
}
#[test]
fn test_double_nested() {
let e = add(add(lit(1), lit(1)), lit(1));
// Outer add: children get doubled
// Inner add(1,1): its children (lit 1) get doubled to (lit 2) → 2+2=4
// But inner add itself was already doubled... depth matters
let result = prepro(&double_lits, &eval_alg, &e);
assert!(result > 3); // exact value depends on depth
}
#[test]
fn test_remove_double_neg() {
assert_eq!(prepro(&remove_neg, &eval_alg, &neg(neg(neg(lit(7))))), 7);
}
}
Deep Comparison
Comparison: Example 225 — Prepromorphism
prepro Definition
OCaml
let rec prepro nat alg (Fix f) =
alg (map_f (fun child ->
prepro nat alg (Fix (nat (unfix child)))
) f)
Rust
fn prepro<A>(
nat: &dyn Fn(ExprF<Fix>) -> ExprF<Fix>,
alg: &dyn Fn(ExprF<A>) -> A,
Fix(f): &Fix,
) -> A {
alg(f.map_ref(|child| {
let transformed = Fix(Box::new(nat(child.0.as_ref().clone())));
prepro(nat, alg, &transformed)
}))
}
Natural Transformation: Mul → Add
OCaml
let mul_to_add = function
| MulF (a, b) -> AddF (a, b)
| other -> other
Rust
fn mul_to_add(e: ExprF<Fix>) -> ExprF<Fix> {
match e {
ExprF::MulF(a, b) => ExprF::AddF(a, b),
other => other,
}
}
Remove Negation
OCaml
let remove_neg = function
| NegF a -> unfix a
| other -> other
Rust
fn remove_neg(e: ExprF<Fix>) -> ExprF<Fix> {
match e {
ExprF::NegF(a) => a.0.as_ref().clone(),
other => other,
}
}
Exercises
nat simplifies Add(Lit(a), Lit(b)) → Lit(a+b) before evaluation.neg_elimination nat that rewrites Neg(Neg(x)) → x at each level.postpro<S>(nat, coalg) and demonstrate it on a list-unfolding example.