Recursion schemes
Status: exploratory. This module is an early exploration of what recursion schemes as optics should look like and how they should be shaped — the API is not yet stable. In particular the engine threads children throughPSVec(aArray[AnyRef]-backed vector): this is very performant but type-unsafe — the per-node child results are erased toAnyRefand the combiner indexes them positionally, so a coalgebra/algebra arity mismatch is a runtime error, not a compile error. Ideas for recovering type safety without losing the stack-safe machine (a typed pattern-functor layer? indexed vectors?) are very welcome.
cats-eo-schemes expresses the recursion schemes as optics, so they compose with the rest
of the optic algebra rather than living in a separate world:
| Scheme | Optic | Direction |
|---|---|---|
cata |
Getter[S, A] (driven by Plated[S]) |
fold an existing S to an A |
ana |
Review[S, Seed] |
build an S from a seed |
hylo |
Getter[Seed, A] (fused — no intermediate S) |
unfold-and-fold in one pass |
All three run on a single stack-safe, post-order machine (heap-stacked, not JVM-call-stacked),
so they are safe to depths a hand-written recursion would overflow. The examples below use the
circe Plated[Json] from cats-eo-circe as a concrete recursive S.
import dev.constructive.eo.schemes.Schemes
import dev.constructive.eo.data.PSVec
import dev.constructive.eo.optics.Getter
import dev.constructive.eo.optics.Optic.* // get, andThen, cross
import dev.constructive.eo.circe.platedJson // given Plated[Json]
import io.circe.Json
cata — a fold that is a Getter
The algebra sees each node plus its already-folded children. Here, sum every number anywhere in a JSON document:
val sumNumbers: Getter[Json, Int] =
Schemes.cata[Json, Int]((node, folded) =>
node.asNumber.flatMap(_.toInt).getOrElse(0) + folded.toList.sum
)
val doc = Json.obj(
"a" -> Json.fromInt(1),
"b" -> Json.arr(Json.fromInt(2), Json.fromInt(3)),
"c" -> Json.fromString("ignored"),
)
sumNumbers.get(doc)
// res0: Int = 6
Because cata is a Getter, it composes onto any optic that ends in the recursive type:
final case class Payload(label: String, body: Json)
val bodySum: Getter[Payload, Int] =
Getter[Payload, Json](_.body).andThen(sumNumbers)
bodySum.get(Payload("p", doc))
// res1: Int = 6
The algebra as an optic — cata(Unfold)
The input of a fold is itself a citizen of the optic algebra: a pure algebra
PSVec[A] => A is an Unfold[A, A, PSVec] — the build-only/many
optic — and cata accepts it directly. The payoff is that algebras can be assembled
by optic composition (Review ∘ Unfold post-processes each layer's result,
Unfold ∘ Review pre-processes each part) before the engine consumes them:
import dev.constructive.eo.optics.{Review, Unfold}
// node-blind algebra (counts nodes) carried as a build-only optic …
val sizeAlg = Unfold.algebra[Int, Int, PSVec](kids => 1 + kids.toList.sum)
// … and a per-layer post-processing step composed in front of it
val weighted = Review[Int, Int](_ * 2).andThen(sizeAlg)
val docSize: Getter[Json, Int] = Schemes.cata[Json, Int](sizeAlg)
docSize.get(doc)
// res2: Int = 6
One honesty note: a PSVec layer is node-blind — the children arrive positionally
with the constructor erased — so a pure PSVec-algebra can only express
constructor-independent folds (sizes, counts, child aggregations). Folds that dispatch
on the node (like sumNumbers above) use the para-flavored (S, PSVec[A]) => A
overload, which stays the primary form on this untyped path.
ana — an unfold that is a Review
A coalgebra maps a seed to its child seeds plus a builder for the node (the canonical anamorphism
shape — children and assembly decided together). It returns a Review, so .reverseGet runs the
unfold:
// seed n builds a right-nested pair tree of depth n, leaves = 1
val buildTree =
Schemes.ana[Int, Json] { n =>
if n <= 0 then (PSVec.empty[Int], (_: PSVec[Json]) => Json.fromInt(1))
else (PSVec.of(0, n - 1), (ks: PSVec[Json]) => Json.arr(ks(0), ks(1)))
}
buildTree.reverseGet(2)
// res3: Json = JArray(
// Vector(
// JNumber(JsonLong(1L)),
// JArray(Vector(JNumber(JsonLong(1L)), JNumber(JsonLong(1L))))
// )
// )
hylo — the fused refold
hylo unfolds and folds in one pass, never building the intermediate structure. The same
expand drives the unfold; alg folds the children's results directly:
// fused: count the leaves of that same tree, with no Json ever constructed
val countLeaves: Getter[Int, Int] =
Schemes.hylo[Int, Int](
expand = n => if n <= 0 then PSVec.empty[Int] else PSVec.of(0, n - 1),
alg = (n, rs) => if n <= 0 then 1 else rs.toList.sum,
)
countLeaves.get(2)
// res4: Int = 3
countLeaves.get(20) // stack-safe; no 2^20-node tree is materialised
// res5: Int = 21
cross — build, then read (structure-preserving)
The core cross combinator joins a build optic to a read optic at their shared middle type.
It is self.reverse.andThen(that): it flips the reversible builder so it reads what it would have
built, then composes. The result is a full Optic, not a collapsed getter — its read
capability follows the composed carrier (.get through a Getter, .getOption through a Prism,
.foldMap through a Fold), and it works cross-carrier via Morph. With ana and cata it
is exactly the materializing hylo (cata ∘ ana — it does build the S):
val refoldSum = buildTree.cross(sumNumbers) // Optic[Int, Unit, Int, Unit, Direct]
refoldSum.get(2)
// res6: Int = 3
refoldSum and the fused countLeaves compute the same value (the hylo law,
hylo == cata ∘ ana); the difference is that the fused hylo never allocates the intermediate
Json, while ana.cross(cata) builds it and then folds. Reach for the fused hylo when the
intermediate structure is large; reach for ana / cata / cross when you want the
intermediate S, or want to drop the schemes into a larger optic pipeline.
cross is overloaded (like andThen): a trait-member overload composes under a single carrier,
and a Morph-bridged overload (same name) composes across carriers — overload resolution picks
the right one. So crossing a builder with a Fold (not just a single-focus getter) bridges
Direct → Forget via Morph and reads many foci from what was built — read-many falls out of
cross:
import dev.constructive.eo.optics.{Fold, Review}
import dev.constructive.eo.data.Forget.given
import cats.instances.list.given
// build a List[Int] from a seed, then fold every element
val buildList = Review[List[Int], Int](n => (1 to n).toList)
val sumBuilt = buildList.cross(Fold[List, Int])
sumBuilt.foldMap[Int](identity)(4) // 1+2+3+4
// res7: Int = 10