diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6978522b..513c2cd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,9 @@ jobs: - name: Check scalafix run: sbt '++ ${{ matrix.scala }}' 'scalafixAll --check' + - name: Compile tests (macro expansion before test-run contention) + run: sbt '++ ${{ matrix.scala }}' Test/compile + - name: Check that workflows are up to date run: sbt githubWorkflowCheck @@ -135,11 +138,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) - run: mkdir -p jsoniter/target benchmarks/target kyo/target target circe/target zio/target unidocs/target avro/target laws/target tests/target generics/target schemes-laws/target schemes/target core/target project/target + run: mkdir -p jsoniter/target benchmarks/target kyo/target target circe/target zio/target unidocs/target avro/target laws/target tests/target generics/target schemes/target core/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) - run: tar cf targets.tar jsoniter/target benchmarks/target kyo/target target circe/target zio/target unidocs/target avro/target laws/target tests/target generics/target schemes-laws/target schemes/target core/target project/target + run: tar cf targets.tar jsoniter/target benchmarks/target kyo/target target circe/target zio/target unidocs/target avro/target laws/target tests/target generics/target schemes/target core/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v')) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40af65a6..2b3b3061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -787,6 +787,53 @@ grep-verified and the one perf-relevant cut B/op-verified: ### Added +- **`cats-eo-schemes` — typed recursion schemes as composable optics.** A new + module whose citizens are optics over a user-supplied **pattern functor** + `F[_]` (+ `Traverse[F]`) and a hand-written `Basis` (`Project[F, S]` / + `Embed[F, S]`): `cata` (Getter-shaped fold), `ana` (Review-shaped unfold), + and the **fused** `hylo` (builds no intermediate `S`), plus the materialising + `ana.cross(cata)` spelling (the hylo law pins the two as equal for a pure + algebra). The **decoration zoo** refines each tower rung by its existential + index — `para` (subterm-retaining, product), `apo` (O(1) subtree graft, sum), + `histo` (course-of-value over the cofree `Attr`), `futu` (multi-layer unfold + over the free `Coattr`), plus `zygo` / `mutu` / `cozygo` / `comutu` between + the towers, the fused `dyna` / `codyna` / `chrono` / `elgot` / `coelgot` + refolds, `meta` / `metaChrono` (the non-fusing fold→unfold seam), and + `prepro` / `postpro` (the natural-transformation axis). The **effectful + `*M` family** (`cataM` / `paraM` / `histoM` / `anaM` / `apoM` / `futuM` / + `hyloM` / `chronoM`) runs the same machine lifted through + `Monad[M].tailRecM` (single-pass, linear `M` contract). `paraLens` promotes + the paramorphism to a lawful `Lens` (caller-supplied coherent put). All + schemes run on one stack-safe `< 512`-on-stack / heap-`ArrayDeque` engine, + tested to 10⁶ depth; the typed path replaces the earlier untyped + `Plated`/`PSVec`-driven schemes (removed — the erased positional indexing + made algebra arity slips a runtime error). + +- **`Affine` wears the build seam — the schemes' decoration carrier, no new carrier.** + The decoration machinery reuses `data.Affine` with its arms read on the build + seam: `Hit(context, focus)` keeps going, `Miss(payload)` means "this slot is + already finished — do not call the coalgebra" (apo grafts by reference, futu + unrolls a prebuilt layer). New in core: the `Graft[Affine]` build-channel + accessor (`done = Miss`, `step = Hit`) — the injection vocabulary the schemes' + build-side citizens construct and consume — plus the corresponding + graft-finality laws in `cats-eo-laws` (the finished arm is focus-free, inert + under `map`, and folds empty). Decoration composition rides Affine's own + composition row (`Affine.assoc` same-carrier `andThen`) and its cross-carrier + bridges from `Tuple2` (Lens) and `Either` (Prism), so + `lens.andThen(apoScatter)`-style compositions resolve. `Schemes.apoScatter` + exposes the `Left(s) → Miss(s)` graft channel as a composable scatter optic. + (An earlier draft shipped a separate `BiAffine` carrier with identical shape; + it was dropped pre-merge — the arms are isomorphic and the duplication bought + nothing. The `BiAffine` name is left free for a genuinely two-sided-failure + carrier if one is ever needed.) + +- **`Basis` in core; `Plated` derives from it.** `optics.Basis` + (`Project[F, S]` / `Embed[F, S]`) — the pattern-functor correspondence the + schemes drive — moves into `cats-eo-core`, and `Plated.fromBasis` derives a + `Plated[S]` from it, the schemes↔`Plated` bridge (`PlatedBridgeSpec` pins + `embed ∘ project` coherence and universe/transform agreement with `cata`). + + - **`Getter`s now compose with `Getter`s via `andThen`.** `g1.andThen(g2)` reads `s => g2.get(g1.get(s))` and yields a `Getter`, matching how `Iso` / `Lens` compose through their fused subclasses. `Getter.apply` now returns a concrete diff --git a/CLAUDE.md b/CLAUDE.md index b8c8e77d..3e0df035 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,6 @@ Test-only: `org.typelevel:discipline-specs2_3:2.0.0`. | `tests` | `tests/` | — (not published) | Law-based and behavioural test suites | | `generics` | `generics/` | `cats-eo-generics` | Auto-derivation of Lens/Prism via Scala 3 quoted macros | | `schemes` | `schemes/` | `cats-eo-schemes` | Recursion schemes (cata/ana/hylo) as composable optics | -| `schemesLaws` | `schemes-laws/` | `cats-eo-schemes-laws` | Laws for the recursion schemes (hylo fusion so far; more expected) — separate from `laws` because they quantify over `schemes` types | | `circe` | `circe/` | `cats-eo-circe` | `Plated[Json]` and circe optic integration | | `avro` | `avro/` | `cats-eo-avro` | Apache Avro optic integration; the `eo.avro.circe` sub-package is the structural Avro ↔ circe bridge (`AvroJson` + the `.json` / `.avro` cursor faces on `AvroPrism`/`JsonPrism`), `eo.avro.jsoniter` the AST-free Avro ↔ JSON-bytes twin (`AvroJsoniter` + the same faces on `JsoniterPrism`), and `eo.avro.vulcan` bridges `vulcan.Codec` → `AvroCodec` (`AvroVulcan`) — circe, cats-eo-circe, jsoniter-scala-core, cats-eo-jsoniter, and vulcan are `Optional` deps, callers add them themselves. NB avro depends on the circe/jsoniter MODULES (Optional); the reverse would be a project cycle, so the cross-format bridge specs live in `avro/src/test` | | `jsoniter` | `jsoniter/` | `cats-eo-jsoniter` | jsoniter-scala optic integration | @@ -30,7 +29,7 @@ Test-only: `org.typelevel:discipline-specs2_3:2.0.0`. | `benchmarks` | `benchmarks/` | — (not published) | JMH benchmarks vs Monocle (not part of root aggregate) | The root project aggregates `core`, `laws`, `tests`, `generics`, `schemes`, -`schemesLaws`, `circe`, `avro`, `jsoniter`, `zio`, and `kyo`. `sbt compile` and `sbt test` +`circe`, `avro`, `jsoniter`, `zio`, and `kyo`. `sbt compile` and `sbt test` cover those; benchmarks must be invoked explicitly (see below). ## Toolchain @@ -140,7 +139,7 @@ Key facts, all the hard-won kind: module-scoped task form reads `loadedTestFrameworks` from the aggregating root project (no test deps), so specs2 is invisible and *every* mutant comes back `NoCoverage`. The `mutationAll` alias uses the - project-switch form across core, laws, generics, schemes, schemesLaws, + project-switch form across core, laws, generics, schemes, circe, avro, jsoniter. - **It's a report, not a gate** (`strykerThresholdsBreak := 0`): a low score never fails the build. diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala index c81538d7..d6b26ff6 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -1,20 +1,24 @@ package dev.constructive.eo package bench +import org.openjdk.jmh.annotations.* +import java.util.concurrent.TimeUnit + +import higherkindness.droste.data.Fix +import higherkindness.droste.scheme + import dev.constructive.eo.bench.fixture.* -import dev.constructive.eo.bench.fixture.PlatedTrees.eoBin // given Plated[Bin] import dev.constructive.eo.bench.fixture.SchemesFixtures.given import dev.constructive.eo.schemes.Schemes -import higherkindness.droste.data.Fix -import higherkindness.droste.scheme -import java.util.concurrent.TimeUnit -import org.openjdk.jmh.annotations.* -/** Recursion schemes — `cata` / `ana` / `hylo` — three ways, on the same workload: +/** Recursion schemes — `cata` / `ana` / `hylo` — four ways, on the same workload: * - * - **eo** — schemes as optics over the *native* `Bin` (`cata` driven by `Plated[Bin]`, `ana` a - * `Review`, fused `hylo` a `Getter`), all on one stack-safe heap machine. - * - **droste** — the pattern-functor + `Fix[BinF]` encoding (`scheme.cata/ana/hylo`). + * - **eoF** — the typed pattern-functor path (`cata`/`ana`/`hylo` over `BinF` via a `Basis` + + * `Traverse[BinF]`) on the stack-safe `foldLayered` heap machine. (The untyped `PSVec` path + * was removed once the typed path subsumed it.) + * - **droste** — the pattern-functor + `Fix[BinF]` encoding (`scheme.cata/ana/hylo`). NB + * droste's *basic* schemes are stack-*unsafe* (naive recursion); `eoF` delivers the + * stack-safety they lack, so the comparison is not apples-to-apples. * - **hand** — plain recursion on `Bin`, the baseline you'd write without either library. * * Workload is a perfect binary tree of `2^Depth` `Leaf(1)`s (Depth = 12 ⇒ 4096 leaves, 8191 @@ -40,9 +44,10 @@ class SchemesBench extends JmhDefaults: val fixTree: Fix[BinF] = balancedFix(Depth) // Prebuilt scheme optics / functions (construction not measured). - val eoCataG = Schemes.cata(eoSum) // DirectGetter[Bin, Int] - val eoHyloG = Schemes.hylo(eoExpand, eoHyloAlg) // DirectGetter[Int, Int] - val eoAnaR = Schemes.ana(eoAnaCoalg) // Review[Bin, Int] + // typed pattern-functor path (Eval trampoline over Traverse[BinF]) + val eoCataG = Schemes.cata(eoTypedSum) // Getter[Bin, Int] + val eoHyloG = Schemes.hylo(eoTypedCoalg, eoTypedHyloAlg) // Getter[Int, Int] + val eoAnaR = Schemes.ana[BinF, Int, Bin](eoTypedCoalg) // Review[Bin, Int] val drosteCataF: Fix[BinF] => Int = scheme.cata(drosteSum) val drosteHyloF: Int => Int = scheme.hylo(drosteSum, drosteBuild) @@ -62,3 +67,61 @@ class SchemesBench extends JmhDefaults: @Benchmark def eoAna: Bin = eoAnaR.reverseGet(Depth) @Benchmark def drosteAna: Fix[BinF] = drosteAnaF(Depth) @Benchmark def handAna: Bin = handBuild(Depth) + + // ----- the zoo: para / apo / histo / futu (eo native routes vs droste.zoo) -- + + val eoParaG = Schemes.para[BinF, Bin, Int](eoParaAlg) + val drosteParaFn: Fix[BinF] => Int = scheme.zoo.para(drosteParaAlg) + val eoApoR = Schemes.apo[BinF, Int, Bin](eoApoCoalg) + val drosteApoFn: Int => Fix[BinF] = scheme.zoo.apo(drosteApoCoalg) + val eoHistoG = Schemes.histo[BinF, Bin, Int](eoHistoAlg) + val drosteHistoFn: Fix[BinF] => Int = scheme.zoo.histo(drosteHistoAlg) + val eoFutuR = Schemes.futu[BinF, Int, Bin](eoFutuCoalg) + val drosteFutuFn: Int => Fix[BinF] = scheme.zoo.futu(drosteFutuCoalg) + + @Benchmark def eoPara: Int = eoParaG.get(eoTree) + @Benchmark def drostePara: Int = drosteParaFn(fixTree) + @Benchmark def eoApo: Bin = eoApoR.reverseGet(Depth) + @Benchmark def drosteApo: Fix[BinF] = drosteApoFn(Depth) + @Benchmark def eoHisto: Int = eoHistoG.get(eoTree) + @Benchmark def drosteHisto: Int = drosteHistoFn(fixTree) + @Benchmark def eoFutu: Bin = eoFutuR.reverseGet(Depth) + @Benchmark def drosteFutu: Fix[BinF] = drosteFutuFn(Depth) + + // ----- apo with ONE BIG GRAFT. VERIFIED (the D6 check): droste's zoo.apo + // ALSO grafts O(1) here — its R is the fixed point, so Left(fix) embeds by + // reference. The honest claim is therefore PARITY on the native routes (both + // ~ns-flat regardless of graft size), with eo adding the law-shaped eq + // guarantee; the O(graft) re-walk contrast applies to the GENERIC distApo + // route (distApo, a law fixture only), not to droste.zoo.apo. + + val eoApoGraftR = Schemes.apo[BinF, Int, Bin] { d => + if d == 0 then BinF.NodeF(Left(eoTree), Right(-1)) else BinF.LeafF(1) + } + + val drosteApoGraftFn: Int => Fix[BinF] = scheme + .zoo + .apo( + higherkindness.droste.RCoalgebra { (d: Int) => + if d == 0 then BinF.NodeF(Left(fixTree), Right(-1)) else BinF.LeafF(1) + } + ) + + @Benchmark def eoApoGraft: Bin = eoApoGraftR.reverseGet(0) + @Benchmark def drosteApoGraft: Fix[BinF] = drosteApoGraftFn(0) + + // ----- materializing refold: cross-spelling vs manual (both build the Bin) -- + // `ana.cross(cata)` is the build⇄read seam — the materialising hylo (builds the + // Bin, then folds), so the two spellings allocate identically. The *fused* + // (no-intermediate-Bin) contrast is `eoHylo` above (~half the B/op). + + val eoRefoldCrossG = Schemes.ana[BinF, Int, Bin](eoTypedCoalg).cross(Schemes.cata(eoTypedSum)) + + @Benchmark def eoRefoldCross: Int = eoRefoldCrossG.get(Depth) + @Benchmark def eoRefoldManual: Int = eoCataG.get(eoAnaR.reverseGet(Depth)) + + // ----- the M path at Id: the tailRecM-lifted machine's per-event floor ------ + + val eoHyloMRunner = Schemes.hyloM[cats.Id, BinF, Int, Int](eoTypedCoalg, fa => eoTypedHyloAlg(fa)) + + @Benchmark def eoHyloM: Int = eoHyloMRunner.get(Depth) diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/SchemesFixtures.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/SchemesFixtures.scala index b958fa5e..089484bb 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/SchemesFixtures.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/SchemesFixtures.scala @@ -2,12 +2,12 @@ package dev.constructive.eo package bench package fixture -import cats.Functor -import dev.constructive.eo.data.PSVec -import dev.constructive.eo.schemes.Schemes +import cats.{Applicative, Eval, Traverse} import higherkindness.droste.data.Fix import higherkindness.droste.{Algebra, Coalgebra} +import dev.constructive.eo.schemes.Basis + /** Pattern functor for the native [[Bin]] tree (`Leaf(Int)` / `Node(Bin, Bin)`). * * droste requires this pattern-functor + `Fix` encoding to express recursion schemes; eo works on @@ -21,12 +21,41 @@ enum BinF[+A]: object SchemesFixtures: - given binFunctor: Functor[BinF] with + /** `Traverse[BinF]` — serves both droste (which needs only `Functor[BinF]`, obtained via the + * `Traverse <: Functor` subtype) and eo's typed schemes (which need the full `Traverse`). A + * single instance avoids an ambiguous `Functor[BinF]` summon. `foldRight` is `Eval`-based so the + * typed driver's trampoline stays lazy. + */ + given binTraverse: Traverse[BinF] with + + def traverse[G[_]: Applicative, A, B](fa: BinF[A])(f: A => G[B]): G[BinF[B]] = + fa match + case BinF.LeafF(v) => Applicative[G].pure(BinF.LeafF(v)) + case BinF.NodeF(l, r) => Applicative[G].map2(f(l), f(r))(BinF.NodeF(_, _)) - def map[A, B](fa: BinF[A])(f: A => B): BinF[B] = + def foldLeft[A, B](fa: BinF[A], b: B)(f: (B, A) => B): B = fa match - case BinF.LeafF(v) => BinF.LeafF(v) - case BinF.NodeF(l, r) => BinF.NodeF(f(l), f(r)) + case BinF.LeafF(_) => b + case BinF.NodeF(l, r) => f(f(b, l), r) + + def foldRight[A, B](fa: BinF[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = + fa match + case BinF.LeafF(_) => lb + case BinF.NodeF(l, r) => f(l, Eval.defer(f(r, lb))) + + /** `Basis[BinF, Bin]` — the `Project`/`Embed` correspondence between the native `Bin` and its + * pattern functor, for the typed `cata`/`ana` benches. + */ + given binBasis: Basis[BinF, Bin] = Basis( + { + case Bin.Leaf(v) => BinF.LeafF(v) + case Bin.Node(l, r) => BinF.NodeF(l, r) + }, + { + case BinF.LeafF(v) => Bin.Leaf(v) + case BinF.NodeF(l, r) => Bin.Node(l, r) + }, + ) // ----- droste algebra / coalgebra (over BinF, on Fix[BinF]) ---------------- @@ -40,25 +69,18 @@ object SchemesFixtures: if d <= 0 then BinF.LeafF(1) else BinF.NodeF(d - 1, d - 1) } - // ----- eo algebra / coalgebra (over native Bin via Plated) ----------------- + // ----- eo TYPED algebras (over the pattern functor BinF via Basis/Traverse) ---------------- - val eoSum: (Bin, PSVec[Int]) => Int = (node, kids) => - node match - case Bin.Leaf(v) => v - case Bin.Node(_, _) => kids(0) + kids(1) + /** Typed cata algebra — the leaf-sum, pattern-matching `BinF`'s named constructors. */ + val eoTypedSum: BinF[Int] => Int = { case BinF.LeafF(v) => v; case BinF.NodeF(l, r) => l + r } - /** Seed expansion shared by eo's hylo/ana: depth `d` ⇒ two child seeds `(d-1, d-1)`; leaf at - * `d <= 0`. `PSVec.of` builds the 2-vector directly (no `List` intermediate). + /** Typed coalgebra (the single fused `Seed => F[Seed]` shape) — builds the perfect binary tree. */ - val eoExpand: Int => PSVec[Int] = d => if d <= 0 then PSVec.empty[Int] else PSVec.of(d - 1, d - 1) - - /** Fused hylo algebra — folds to `Int` directly, never building a `Bin`. */ - val eoHyloAlg: (Int, PSVec[Int]) => Int = (d, rs) => if d <= 0 then 1 else rs(0) + rs(1) + val eoTypedCoalg: Int => BinF[Int] = d => + if d <= 0 then BinF.LeafF(1) else BinF.NodeF(d - 1, d - 1) - /** ana coalgebra (bundled) — each seed's child seeds + how to assemble a native `Bin`. */ - val eoAnaCoalg: Schemes.Coalg[Int, Bin] = d => - if d <= 0 then (PSVec.empty[Int], (_: PSVec[Bin]) => Bin.Leaf(1)) - else (PSVec.of(d - 1, d - 1), (ks: PSVec[Bin]) => Bin.Node(ks(0), ks(1))) + /** Typed fused-hylo algebra — folds to `Int` directly, never building a `Bin`. */ + val eoTypedHyloAlg: BinF[Int] => Int = { case BinF.LeafF(_) => 1; case BinF.NodeF(l, r) => l + r } // ----- hand-wired recursion (the baseline you'd write without either lib) -- @@ -80,3 +102,48 @@ object SchemesFixtures: def balancedFix(d: Int): Fix[BinF] = if d <= 0 then Fix(BinF.LeafF(1)) else Fix(BinF.NodeF(balancedFix(d - 1), balancedFix(d - 1))) + + // ----- zoo fixtures (para / apo / histo / futu — eo vs droste) ------------- + + import higherkindness.droste.{CVAlgebra, CVCoalgebra, RAlgebra, RCoalgebra} + import higherkindness.droste.data.{Attr => DAttr, Coattr => DCoattr} + import dev.constructive.eo.schemes.zoo.{Attr => EoAttr, Coattr => EoCoattr} + + // para: the same leaf-sum with subterms IGNORED — measures pure decoration + // overhead (eo pairs subterms from the walked nodes; droste re-embeds each). + val eoParaAlg: BinF[(Bin, Int)] => Int = { + case BinF.LeafF(v) => v; case BinF.NodeF((_, l), (_, r)) => l + r + } + + val drosteParaAlg: RAlgebra[Fix[BinF], BinF, Int] = RAlgebra { + case BinF.LeafF(v) => v + case BinF.NodeF((_, l), (_, r)) => l + r + } + + // apo, never grafting: the build-side decoration overhead row. + val eoApoCoalg: Int => BinF[Either[Bin, Int]] = d => + if d <= 0 then BinF.LeafF(1) else BinF.NodeF(Right(d - 1), Right(d - 1)) + + val drosteApoCoalg: RCoalgebra[Fix[BinF], BinF, Int] = RCoalgebra { d => + if d <= 0 then BinF.LeafF(1) else BinF.NodeF(Right(d - 1), Right(d - 1)) + } + + // histo, heads only: the course-of-value bookkeeping cost. + val eoHistoAlg: BinF[EoAttr[BinF, Int]] => Int = { + case BinF.LeafF(v) => v; case BinF.NodeF(l, r) => l.head + r.head + } + + val drosteHistoAlg: CVAlgebra[BinF, Int] = CVAlgebra { + case BinF.LeafF(v) => v + case BinF.NodeF(l, r) => DAttr.un(l)._1 + DAttr.un(r)._1 + } + + // futu, single layer per step: the free-wrapper cost. + val eoFutuCoalg: Int => BinF[EoCoattr[BinF, Int]] = d => + if d <= 0 then BinF.LeafF(1) + else BinF.NodeF(EoCoattr.Pure(d - 1), EoCoattr.Pure(d - 1)) + + val drosteFutuCoalg: CVCoalgebra[BinF, Int] = CVCoalgebra { d => + if d <= 0 then BinF.LeafF(1) + else BinF.NodeF(DCoattr.pure(d - 1), DCoattr.pure(d - 1)) + } diff --git a/build.sbt b/build.sbt index f3c8e3c7..00a4b31d 100644 --- a/build.sbt +++ b/build.sbt @@ -72,6 +72,18 @@ ThisBuild / githubWorkflowBuildPreamble ++= Seq( List("scalafixAll --check"), name = Some("Check scalafix"), ), + // Compile ALL test sources before any test RUNS: on the shared 2-vCPU runner, + // sbt otherwise interleaves one module's test execution with another module's + // test compilation, and the Kindlings/hearth derivation macros (hardcoded 2s + // MIO budget per derive in kindlings 0.1.x — no -Xmacro-settings override + // exists) lose that CPU-contention dice roll: observed as roving + // `Macro 'KindlingsEncoder.deriveAsObject' timed out` failures at a different + // derive site each run. Compiling first gives macro expansion the whole CPU; + // the subsequent `test` step then recompiles nothing. + WorkflowStep.Sbt( + List("Test/compile"), + name = Some("Compile tests (macro expansion before test-run contention)"), + ), ) // ------------------------------------------------------------------- @@ -585,7 +597,6 @@ lazy val root: Project = project jsoniterIntegration, zioIntegration, schemes, - schemesLaws, ) ++ (if (kyoBuildActive) Seq[ProjectReference](kyoIntegration) else Seq.empty)) * ) .settings(commonSettings *) @@ -663,24 +674,17 @@ lazy val schemes: Project = project libraryDependencies += cats, libraryDependencies += discipline % Test, libraryDependencies += scalacheck % Test, - ) - -// Discipline-style laws for the recursion-scheme module. Lives outside -// `laws` because the statements quantify over `schemes` types (Coalg, -// cata / ana / hylo) and `laws` sits upstream of `schemes` in the build -// graph. First citizen is the hylo fusion law; more scheme laws are -// expected to land here (para / apo / histo fusion, cata-compose, ...). -lazy val schemesLaws: Project = project - .in(file("schemes-laws")) - .dependsOn(LocalProject("schemes")) - .settings(commonSettings *) - .settings(scala3LibrarySettings *) - .settings( - name := "cats-eo-schemes-laws", - libraryDependencies += cats, - libraryDependencies += disciplineCore, - libraryDependencies += scalacheck, - libraryDependencies += discipline % Test, + // The schemes suites assert stack-safety by folding/building 10^6-deep + // spines. Each engine takes a bounded on-stack prefix (`OnStackLimit` + // = 512 native frames) before handing deep subtrees to the heap walk, + // so 512 `rec` frames are live at once — fine on a main-sized stack, + // but specs2's *parallel* pool threads default to a much smaller stack, + // and several such tests running concurrently overflow it. Fork a test + // JVM with a generous per-thread stack so the parallel runner's threads + // can hold the on-stack prefix; this keeps the realistic 10^6 tests + // (rather than shrinking them) and preserves parallel execution. + Test / fork := true, + Test / javaOptions += "-Xss8m", ) // Auto-derivation of optics for product / sum types via quoted macros, @@ -1211,7 +1215,6 @@ addCommandAlias( "project laws; stryker; " + "project generics; stryker; " + "project schemes; stryker; " + - "project schemesLaws; stryker; " + "project circeIntegration; stryker; " + "project avroIntegration; stryker; " + "project jsoniterIntegration; stryker; " + diff --git a/core/src/main/scala/dev/constructive/eo/accessor/Graft.scala b/core/src/main/scala/dev/constructive/eo/accessor/Graft.scala new file mode 100644 index 00000000..dc28ea94 --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/accessor/Graft.scala @@ -0,0 +1,27 @@ +package dev.constructive.eo +package accessor + +import data.{Fst, Snd} + +/** Build-channel injection for carriers with a *finished* arm — the vocabulary a generic + * recursion-scheme driver needs to feed a decoration's build seam without knowing the concrete + * variants. + * + * [[done]] injects an already-finished payload: the consumer must treat it as final (an apo graft + * places it in the result slot as-is; a futu unroll expands the prebuilt layer without consulting + * the coalgebra again). [[step]] injects a focus alongside its leftover context — the keep-going + * arm. + * + * The payload *meaning* of `done` is pinned per optic value via the existential `X` (`Fst[X]`), + * not by this capability — see the `Gather`/`Scatter` decoration optics in `cats-eo-schemes`. + * + * @tparam F + * the carrier + */ +trait Graft[F[_, _]]: + + /** Inject an already-finished payload — no further building for this slot. */ + def done[X, B](fst: Fst[X]): F[X, B] + + /** Inject a focus `b` alongside its leftover context — keep building. */ + def step[X, B](snd: Snd[X], b: B): F[X, B] diff --git a/core/src/main/scala/dev/constructive/eo/data/Affine.scala b/core/src/main/scala/dev/constructive/eo/data/Affine.scala index 4248a131..de8d86ef 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Affine.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Affine.scala @@ -3,6 +3,7 @@ package data import cats.{Applicative, Monoid} +import accessor.Graft import forgetful.* import compose.* import optics.Optic @@ -26,6 +27,10 @@ type Snd[T] = T match * carried through an `Optic[…, Affine]` existential, `A` is abstract and the match types stay * inert. * + * Affine is also the **build-seam** decoration carrier (see [[Affine.graft]]): `Miss` = the slot + * is finished (no coalgebra call), `Hit` = keep going. Same data shape both ways — no separate + * carrier is needed. + * * @tparam A * existential leftover tuple * @tparam B @@ -219,3 +224,17 @@ object Affine: xb match case m: Miss[X] => o.from(Left(m.fst)) case h: Hit[X, B] => o.from(Right(h.b)) + + /** `Graft[Affine]` — the build-channel injection vocabulary, reading Affine's arms on its *build* + * seam: [[Miss]] is the arm where the engine does not call the coalgebra for the slot (an apo + * graft places the payload by reference; a futu unroll expands a prebuilt layer — "finished"), + * [[Hit]] the keep-going arm (focus alongside its one-layer leftover context). This is the + * decoration vocabulary the recursion-scheme zoo's build-side citizens (`apo`'s scatter, + * `futu`'s unroll) construct and consume; the payload *meaning* of `done` is pinned per optic + * value via the existential `X` (`Fst[X]`), not here. + * + * @group Instances + */ + given graft: Graft[Affine] with + def done[X, B](fst: Fst[X]): Affine[X, B] = new Miss[X](fst) + def step[X, B](snd: Snd[X], b: B): Affine[X, B] = new Hit[X, B](snd, b) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Basis.scala b/core/src/main/scala/dev/constructive/eo/optics/Basis.scala new file mode 100644 index 00000000..388b0c23 --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/optics/Basis.scala @@ -0,0 +1,80 @@ +package dev.constructive.eo +package optics + +import cats.Traverse + +import data.{ObjArrBuilder, PSVec} + +/** The user-supplied bridge between a recursive type `S` and one *layer* of its **pattern functor** + * `F[_]` — the correspondence the typed recursion schemes (`cata` / `ana` / `hylo`, in the + * `schemes` module) and the [[Plated.fromBasis]] derivation are built on. + * + * A pattern functor replaces `S`'s recursive positions with a type parameter: + * {{{ + * enum Bin: case Leaf(n: Int); case Branch(l: Bin, r: Bin) + * enum BinF[A]: case LeafF(n: Int); case BranchF(l: A, r: A) // recursion → A + * }}} + * [[Project]] peels one layer off (`S => F[S]`), [[Embed]] glues one layer back on (`F[S] => S`). + * A driver then walks `F` with the user's `Traverse[F]`, so algebras pattern-match `F`'s **named + * constructors** (`case BranchF(l, r) => l + r`) instead of indexing an erased vector. + * + * Unlike the `F` type itself (which the user must write — Scala-3 macros emit terms, not type + * definitions), `Project`/`Embed` are ordinary instances, expected hand-written (droste's model). + * + * '''Coherence laws''' (the `S`↔`F` correspondence is hand-maintained and NOT compiler-checked — a + * swapped or non-exhaustive mapping is a silent bug, so these are exercised by the typed-scheme + * law suite): + * {{{ + * embed(project(s)) == s // round-trip through one layer of S + * project(embed(fs)) == fs // round-trip through one layer of F + * }}} + */ +trait Project[F[_], S]: + + /** Peel one layer: expose `S`'s immediate children as `F`'s recursive slots. */ + def project(s: S): F[S] + +/** @see [[Project]] — the dual, gluing one `F`-layer back into an `S`. */ +trait Embed[F[_], S]: + + /** Glue one layer: rebuild an `S` node from an `F` of already-built children. */ + def embed(fs: F[S]): S + +/** Both halves of the `S`↔`F` correspondence in one instance — the convenience an implementor + * reaches for when supplying `project` and `embed` together. A `given Basis` satisfies both a + * `Project` and an `Embed` requirement. + */ +trait Basis[F[_], S] extends Project[F, S], Embed[F, S] + +object Basis: + + /** Build a [[Basis]] from the two halves. */ + def apply[F[_], S](projectFn: S => F[S], embedFn: F[S] => S): Basis[F, S] = + new Basis[F, S]: + def project(s: S): F[S] = projectFn(s) + def embed(fs: F[S]): S = embedFn(fs) + + /** The immediate children of one `S` layer as a freshly-allocated [[PSVec]] — `project` then a + * single-pass copy of `F`'s recursive slots into the vector the [[Plated]] read/write paths + * share. Fresh per call, so [[Plated.fromChildrenVec]]'s copy-free contract holds. + */ + private[optics] def childrenVec[F[_], S](s: S)(using F: Traverse[F], P: Project[F, S]): PSVec[S] = + val fa = P.project(s) + val b = new ObjArrBuilder(F.size(fa).toInt) + val _ = F.foldLeft(fa, ())((_, child) => b.unsafeAppend(child.asInstanceOf[AnyRef])) + PSVec.unsafeWrap(b.freezeArr) + + /** Rebuild a layer with new children swapped in (same arity / `Foldable` order) — `embed` after + * threading the vector's elements back through `F.map`. The order match is the same lawful + * `Traverse` assumption [[childrenVec]] relies on. + */ + private[optics] def rebuild[F[_], S](parent: S, kids: PSVec[S])(using + F: Traverse[F], + P: Project[F, S], + E: Embed[F, S], + ): S = + var i = -1 + E.embed(F.map(P.project(parent)) { _ => + i += 1 + kids(i) + }) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Getter.scala b/core/src/main/scala/dev/constructive/eo/optics/Getter.scala index 11292a0f..fe92e886 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Getter.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Getter.scala @@ -71,6 +71,25 @@ final class Getter[S, A](read: S => A) ): rc.Out[S, C] = rc.compose(this, inner) + /** Fused `Getter.andThen(Direct-carried read-only citizen)` — the C8 tie-breaker. The schemes' + * zoo citizens (`cata`/`ana`/`hylo` via `ReadScheme`/`BuildScheme`) are `Optic[…, Unit, …, Unit, + * Direct]` — simultaneously matching the any-carrier member, the re-homed read-only override, + * and the trait's same-carrier `inline andThen`; for that argument dotty called those three a + * draw (each wins on one of signature specificity / owner derivation / same-carrier matching; + * the fused `andThen(Getter)` does not apply — citizens are not statically `Getter`). This + * member's parameter type pins the full citizen shape (`inner.T = Unit`, `inner.B = Unit`, + * carrier `Direct`), making it strictly the most specific in the set, so it wins outright — and + * it returns the concrete `Getter` (what `ReadCompose.totalTotal` would produce) rather than a + * bare `Optic`, so ascribed compositions (`val g: Getter[Doc, Int] = …`) type-check. The + * `DummyImplicit` keeps its parameter-list shape comparable with the other overloads (all term + + * using), which is what lets the specificity comparison run at all. + */ + @annotation.targetName("andThenDirectReadOnly") + inline def andThen[C, D](inner: Optic[A, Unit, C, Unit, Direct])(using + scala.DummyImplicit + ): Getter[S, C] = + new Getter(s => inner.to(get(s)).value) + /** Constructor for `Getter` — read-only single-focus optic, backed by `Direct` with `T = B = Unit`. * `.get(s)` is the only meaningful operation; the write path is vestigial. * diff --git a/core/src/main/scala/dev/constructive/eo/optics/Plated.scala b/core/src/main/scala/dev/constructive/eo/optics/Plated.scala index 75da85b6..982d763d 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Plated.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Plated.scala @@ -3,7 +3,7 @@ package optics import scala.annotation.tailrec -import cats.Eval +import cats.{Eval, Traverse} import java.util.ArrayDeque import data.{ModifyF, MultiFocus, PSVec} @@ -100,6 +100,20 @@ object Plated: (s, vec) => rebuild(s, vec.toList), ) + /** Derive a [[Plated]] from a pattern-functor [[Basis]] — the bridge from the typed + * recursion-scheme world (`schemes` module) into core's `Plated` recursion combinators + * ([[transform]] / [[rewrite]] / [[children]] / [[universe]]) and the `MultiFocus[PSVec]` + * carrier. The immediate children are `project`'s recursive slots; `rebuild` is `embed` with the + * new children threaded back through `F.map`. + * + * A scheme's single layer is exactly this self-traversal; `Plated` is its non-recursive face. + * Use it to register the instance: `given Plated[Bin] = Plated.fromBasis[BinF, Bin]` (`F` cannot + * be inferred from `S` alone, so name it). The children vector is freshly allocated per call, so + * the copy-free [[fromChildrenVec]] contract holds. + */ + def fromBasis[F[_], S](using Traverse[F], Project[F, S], Embed[F, S]): Plated[S] = + fromChildrenVec(Basis.childrenVec[F, S](_), Basis.rebuild[F, S](_, _)) + /** Largest call-stack recursion depth [[transform]] takes before handing a subtree to the heap * machine. Tree *depth*, not node count — a balanced tree of a billion nodes is ~30 deep, so it * stays entirely on the fast recursive path; only a degenerate spine deeper than this crosses diff --git a/core/src/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala b/core/src/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala new file mode 100644 index 00000000..78d04292 --- /dev/null +++ b/core/src/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala @@ -0,0 +1,65 @@ +package dev.constructive.eo +package optics + +import org.specs2.mutable.Specification + +import data.Direct + +/** Pins `Getter.andThen` overload resolution (the C8 fix). The pre-fix `Getter` carried a + * class-level any-carrier member + a re-homed read-only twin, which made + * `getter.andThen(DirectCarriedCitizen)` — e.g. the schemes' `cata`/`ana`/`hylo` — a documented + * three-way tie (E051). With only the trait members in the overload set, dotty resolves by + * specificity; this spec pins the three routes and their result types. + */ +class GetterAndThenResolutionSpec extends Specification: + + // --- fixtures -------------------------------------------------------------- + + case class Doc(id: Int, tag: String, tree: Bin) + + enum Bin: + case Leaf(n: Int) + case Branch(l: Bin, r: Bin) + + val binTree = Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) + + val leafSum: Getter[Bin, Int] = Getter[Bin, Int](leafSumFold) + + private def leafSumFold(s: Bin): Int = s match + case Bin.Leaf(n) => n + case Bin.Branch(l, r) => leafSumFold(l) + leafSumFold(r) + + val treePick = PickFold[Bin, String] { + case Bin.Leaf(_) => Some("leaf") + case Bin.Branch(_, _) => None + } // read-only, OTHER carrier (Affine) — T = Unit but G = Affine + + val getter = Getter[Doc, Bin](_.tree) + + // --- the three routes ------------------------------------------------------ + + "getter.andThen(getter) resolves to the fused member → a plain Getter" >> { + val g = getter.andThen(leafSum) + (g: Getter[Doc, Int]).get(Doc(7, "t", binTree)) === 6 // 1 + 2 + 3 + } + + "getter.andThen(writable lens inner) resolves via the trait's read-only member → rc.Out" >> { + val g = + Getter[Doc, Bin](_.tree).andThen(treePick) // read-only inner, other carrier → trait overload + (g.pick(Doc(7, "t", binTree)) === None) + .and(g.pick(Doc(7, "t", Bin.Leaf(9))) === Some("leaf")) + } + + "getter.andThen(Direct-carried read-only citizen) resolves — was the C8 tie" >> { + // A Direct-carried Getter-shaped optic that is NOT a concrete `Getter` — the exact shape the + // schemes' zoo citizens have. Worn as the erased trait type so the static `Getter` fast path + // is defeated and the trait member route is exercised. + val citizen: Optic[Bin, Unit, Int, Unit, Direct] = + new Optic[Bin, Unit, Int, Unit, Direct]: + type X = Nothing + def to(s: Bin): Direct[X, Int] = Direct(leafSumFold(s)) + def from(d: Direct[X, Unit]): Unit = () + + val g = Getter[Doc, Bin](_.tree).andThen(citizen) + (g: Getter[Doc, Int]).get(Doc(7, "t", binTree)) === 6 // 1 + 2 + 3 + } diff --git a/docs/brainstorms/2026-06-12-elgot-seam-sketch.md b/docs/brainstorms/2026-06-12-elgot-seam-sketch.md new file mode 100644 index 00000000..c8b310ff --- /dev/null +++ b/docs/brainstorms/2026-06-12-elgot-seam-sketch.md @@ -0,0 +1,47 @@ +--- +date: 2026-06-12 +topic: elgot-seam-sketch +spike: gate artifact (plan 2026-06-11-001, stage 5 pre-commit gate) +--- + +# Does elgot fit the v1 Decor/driver seam? (the decision-11 check) + +One page, per the plan's stage-5 gate: sketch the elgot decoration against the v1 +signatures BEFORE the M-driver lands. Fail action was: a public-signature change lands +in stage 5, or decision 11 re-opens. + +## The shapes (arbo's `elgot/package.scala`) + +```scala +elgot: alg: F[B] => B, coalg: A => Either[B, F[A]] // answer-level short-circuit +elgotM: alg: F[B] => B, coalgM: A => M[Either[B, F[A]]] // the Calculator.selection shape +``` + +The `Either` sits **outside** the layer and carries an **answer** `B` — not a finished +structure (apo's `Done(S)`) and not a prebuilt layer (futu's `Done(F[W])`). + +## Findings + +1. **Elgot is a refold, not an unfold** — its driver seam is hylo-family (`Seed => B`, + no `S` ever built), NOT `anaF`. So elgot never needed to fit `DecorScatter`'s + `Done = F[W]` pinning: the follow-up adds a third sub-shape + (`DecorElgot[F, W, A, B] = Optic[W, W, A, A, BiAffine] { type X = (B, Unit) }` — + `Done` carries the ANSWER) plus one driver (`elgotF`/`elgotFM`). Additive; no v1 + alias or signature changes. + +2. **The v1 M-machine adopts the Or-shape NOW** — `foldLayeredM`'s expand is + `N => M[Either[R, F[N]]]` internally (the `foldLayeredOr` shape lifted into M). + `hyloFM`/`anaFM`/`cataFM` always pass `Right`; the elgot follow-up (and an `apoFM`) + merely supply `Left` answers. This is the one concrete "land ready for it" choice, + and it costs v1 nothing (one constant `Right` wrapper per node event, folded into + the `Either` the tailRecM step allocates anyway). + +3. **`Forget[M]` citizenship is unaffected** — `elgotFM` returns the same + `Seed => M[B]` fold shape (`FoldFM`) as `hyloFM`. + +## Verdict + +**PASS — no v1 public-signature change required.** Decision 11 stands: the follow-up +adds values (`DecorElgot`, `Decor.elgot`, `Decor.coelgot`) + one driver seam +(`elgotF`/`elgotFM`), with the full arbo `Calculator.selection` port as its acceptance +test. The only v1 accommodation is internal: `foldLayeredM`'s Or-shaped expand. diff --git a/docs/brainstorms/2026-06-12-existential-x-is-the-decoration.md b/docs/brainstorms/2026-06-12-existential-x-is-the-decoration.md new file mode 100644 index 00000000..3bcd39ec --- /dev/null +++ b/docs/brainstorms/2026-06-12-existential-x-is-the-decoration.md @@ -0,0 +1,78 @@ +--- +date: 2026-06-12 +topic: existential-x-is-the-decoration +spike: open (raised in PR #24 review — FoldM.scala thread) +--- + +# X = Nothing is a choice: the existential IS the decoration + +## The observation (kryptt, PR #24) + +> I see `X = Nothing` here and in AnaM, CataM while we have Attr and Coattr that pretty +> much are exactly the extra info needed on every to/from step. […] are we actually +> missing a very beautiful connection between optics and recursion schemes? + +Yes — and naming it reorganizes the whole module's story. + +## The connection + +In eo's encoding, an optic is `(to: S => F[X, A], from: F[X, B] => T)` and **X is the +leftover** — whatever `to` must retain for `from` to rebuild. The scheme citizens pin +`X = Nothing` because `Direct`/`Forget` carriers are **forgetful**: a `Cata` worn as a +Getter throws away everything except the answer. That is a *choice of existential +resolution*, not a fact about folds. + +What would a non-forgetful fold retain? Exactly the decoration: + +| scheme | whole-scheme X | which optic it makes the fold | +|---|---|---| +| cata | `Nothing` | Getter — the forgetful projection | +| **para** | `F[(S, A)]` — subterms retained | **a lawful Lens**: `from` re-embeds the retained subterms, so get-put holds *definitionally* | +| **histo** | `Attr[F, A]` — the full memo | the iterated Lens: cofree = νX. A × F[X] | +| **apo** | `Either`-residual on the build | the Prism's match, worn build-side | +| **futu** | `Coattr[F, A]` | the iterated Prism residual: free = μX. A + F[X] | + +Two readings of the same fact: + +1. **Per layer:** a `Gather` optic's leftover is one F-layer of W — `X = (Unit, F[W])`. + **Whole scheme:** the fixpoint of that per-layer leftover. `Attr[F, A] = νX. A × F[X]` + is *literally* the fixpoint of the gather-side leftover; `Coattr[F, A] = μX. A + F[X]` + of the scatter-side. **Attr/Coattr are not auxiliary data types — they are the + universal existentials of decorated schemes.** The Gather/Scatter optics manufacture + X layer-by-layer; the engine's out-array of W's is the X being threaded. + +2. **Comonadically:** a lawful lens is a coalgebra of the store comonad (Riley; the + lens complement is the store's "position"). histo is gcata over the **cofree** + comonad — the iterated store. So histo : cata :: Lens : Getter, with `Attr` playing + the complement. The plan's sum/product symmetry table (para = Tuple2/Lens carrier, + apo = Either/Prism carrier) is the same statement made per-layer; this is it made + whole-scheme, at the X seam. + +The sharpest corollary: **deforestation is choosing the forgetful existential.** +`ana.cross(cata)` fused (no S built) vs materializing (S built) are the *same optic at +two X-resolutions* — `X = Nothing` vs `X = S` (or `Attr` for the memoized middle). The +fused/materializing pair we law-pinned is an instance of a general principle: refining +X from `Nothing` upward trades allocation for capability. + +## What it could buy (follow-up candidates, in rough order of value) + +1. **para-as-Lens** — `Optic[S, S, A, A, Tuple2] { type X = F[(S, A)] }`: get = fold, + put = re-embed retained subterms with the new focus. get-put is definitional; + put-get is the algebra-coherence law. The first *lawful writable* recursion scheme. +2. **Memoized refolds** — `cata.withHistory: X = Attr[F, A]`: hold the memo, modify, + re-fold incrementally (only the spine above a change recomputes). Lens laws become + memo-coherence laws. This is the incremental-computation story (Adapton-flavored) + falling out of optic laws. +3. **The honest hylo optic** — expose the fused/materializing choice as an X + parameter instead of two spellings. +4. **BiAffine's matrix row** — composing decorated schemes = composing their Xs; + the `(W, F[W])` tuples compose exactly like Affine's existentials, which is what + the deferred AssociativeFunctor[BiAffine] instance will thread. + +## Recommendation + +Not in PR #24 — it lands the forgetful citizens + the per-layer decoration optics, +which are the substrate. This spike is the natural *third* act after the elgot +follow-up: elgot completes the decoration vocabulary; this completes the existential +story (and would be the paper-worthy claim: "recursion schemes are optics indexed by +their existential; the (co)free (co)monads are the universal indices"). diff --git a/docs/plans/2026-06-09-002-feat-typed-recursion-schemes-plan.md b/docs/plans/2026-06-09-002-feat-typed-recursion-schemes-plan.md new file mode 100644 index 00000000..9ab2c9bd --- /dev/null +++ b/docs/plans/2026-06-09-002-feat-typed-recursion-schemes-plan.md @@ -0,0 +1,561 @@ +--- +title: "feat: Typed pattern-functor recursion schemes (cataF/anaF/hyloF)" +type: feat +status: completed +date: 2026-06-09 +origin: docs/brainstorms/2026-06-09-pattern-functor-carrier-requirements.md +deepened: 2026-06-09 +--- + +# feat: Typed pattern-functor recursion schemes (cataF/anaF/hyloF) + +## Overview + +Add an **opt-in, typed** recursion-scheme path to `cats-eo-schemes`, complementing — not +replacing — PR #23's Plated-driven `Schemes.cata/ana/hylo`. The user supplies a pattern functor +`F[_]` (e.g. `enum BinF[A] { case LeafF(n: Int); case BranchF(l: A, r: A) }`) plus its +`Traverse[F]`, and hand-writes two instances, `Project[F, S]` (`project: S => F[S]`) and +`Embed[F, S]` (`embed: F[S] => S`). From those, `Schemes.cataF/anaF/hyloF` give recursion schemes +whose algebra/coalgebra **pattern-match `F`'s named constructors** — no `PSVec[AnyRef]`, no +positional indexing, no `IndexOutOfBounds`. The driver is stack-safe via a `cats.Eval` trampoline +over `Traverse[F]` (droste's stack-safe `hyloM` shape), and the returned optics are the same +`DirectGetter`/`Review` types #23 produces, so they compose with the rest of the optic algebra via +`andThen`/`cross`. + +This is **encoding B** from the corecursion spike (see origin and +`docs/research/2026-06-08-corecursion-encoding-spike.md`): a *thin opt-in typed layer*, justified +solely by two differentiators over droste's **basic** schemes — **stack-safety** (droste's +`kernel.hylo` is naive recursion) and **optic-composability**. It is not eo's "no pattern functor" +story; that remains #23/encoding A. + +## Problem Frame + +#23's schemes thread children through `PSVec[AnyRef]`: stack-safe and fast, but **type-unsafe** — +the algebra receives an erased `PSVec[A]` and indexes it positionally, so an arity mismatch is a +runtime `IndexOutOfBounds` or a silently-dropped subtree, not a compile error. Users who want +**named-constructor type safety** on a recursion scheme have nothing in eo today. droste is typed +but its *basic* schemes are stack-unsafe and don't compose as optics. The gap: a typed path that is +*also* stack-safe and optic-composable. (see origin: Problem Frame.) + +## Requirements Trace + +- **R1.** `project`/`embed` form an `Optic[S, S, S, S, Forget[F]]` using the **existing** `Forget[F]` + carrier, with **no change to the `Optic` trait** (spike-proven G2). Realized as a `Schemes.fLayer` + constructor and verified by a test that it is a usable `Optic` over `Forget[F]`. +- **R2.** A stack-safe driver over `to`/`from` provides `cataF`/`anaF`/`hyloF`, **empirically** + stack-safe to 10⁶ in **O(depth) auxiliary space**. (The 2026-06-09 carrier-fit spike's *typed + `Eval` cata* reached 10⁵; #23's `PSVec` machine reached 10⁶ but via a **different** engine + (`ArrayDeque`/`tailRecM`, not `Traverse[F]`+`Eval`). 10⁶ on the `Eval` driver is therefore a *new + bar to test, not assert* — and "stack-safe" here must mean **space-safe under a bounded heap**, not + merely trampolined off the JVM call stack; see U4.) +- **R3.** Type-safe **at the algebra seam**: `gather`/`alg`/`coalg` pattern-match `F`'s typed + constructors, so child-*arity* mismatches are compile errors (no `PSVec[AnyRef]`, no positional + indexing). The honest scope of the claim: the `S`↔`F` *constructor correspondence* lives in the + hand-written `Project`/`Embed` and is **not** compiler-checked — a non-exhaustive `project` is a + runtime `MatchError`, a swapped mapping is silently wrong — guarded only by the user-run coherence + laws (U4). So R3 is "type-safe destructure + law-checked correspondence", **not** "every mismatch + structurally impossible". This is still a strict improvement over #23's positional `PSVec[AnyRef]`. +- **R4.** #23's `Schemes.cata/ana/hylo` and the `PSVec` engines stay **byte-for-byte unchanged** — + the default path. Not subsumed. +- **R5.** New methods: `cataF(gather: (S, F[A]) => A)`, `anaF(coalg: Seed => F[Seed])`, + `hyloF(coalg, alg)`. Gather is para-flavored `(S, F[A]) => A` (dual of droste's `(A, F[S]) => S`); + pure `F[A] => A` is the degenerate case (ignore the `S`). +- **R6.** The user **writes `F` and its `Traverse[F]`** and **hand-writes** `Project[F, S]` / + `Embed[F, S]` (droste's model). Derivation of `Project`/`Embed` is **deferred to a follow-up PR**. +- **R7.** Typed schemes compose with the optic algebra via `andThen` (and `cross`) — `cataF` returns + `DirectGetter`, `anaF` returns `Review`, like #23 — so composition works through the `Direct` + carrier with **no new core carrier instances**. +- **R8.** v1 = `cataF`/`anaF`/`hyloF` only. The zoo (para/apo/histo/futu) is deferred; the + Gather/Scatter shape supports it later. + +### Success Criteria + +- A user-supplied `F` + `Traverse[F]` + hand-written `Project`/`Embed` yields `cataF`/`anaF`/`hyloF` + that are typed (pattern-match `F`'s ctors) and **empirically** stack-safe at 10⁶. +- The typed schemes compose with the optic algebra via `andThen` (and `cross` for the materializing + hylo law). +- #23's API compiles and behaves unchanged (regression suite green). +- Allocation (extend `SchemesBench`, `-prof gc`, **B/op**): typed-F path **at parity with droste's + basic schemes** — net-better because eo also delivers the stack-safety droste's basic path lacks. + (Beating droste needs a specialized `F`; out of scope.) **This parity is a *measured target* (U6), + not a v1 merge gate** — if the `Eval` driver misses it, v1 still ships and the explicit-heap-machine + fallback is filed as a follow-up (consistent with Key Technical Decisions and U6). What v1 *must* + demonstrate to merge: typed correctness, the laws, and empirical 10⁶ space-safety. + +## Scope Boundaries + +- **Complement, not subsume/replace** #23 — #23 stays primary and untouched. +- The pattern functor `F` is **user-written**; eo does **not** derive the `F` type (proven + impossible — G3) and, per the chosen scope, does **not** derive `Project`/`Embed` in v1 either. +- v1 schemes: `cataF`/`anaF`/`hyloF`. The zoo is deferred. +- Not chasing a boxing/allocation *win* over droste — parity with droste **basic** is the bar. +- No `core`, `generics`, or `build.sbt` module changes; no CI workflow regeneration (everything + lands inside the existing `cats-eo-schemes` module). + +## Context & Research + +### Relevant Code and Patterns + +- **`core/src/main/scala/dev/constructive/eo/data/Forget.scala`** — `type Forget[F[_]] = [X, A] =>> F[A]` + (transparent, `X` phantom). Capability ladder, all gated on a type class of `F`: `Functor → + ForgetfulFunctor` (`.modify`), `Foldable → ForgetfulFold` (`.foldMap`), `Traverse → + ForgetfulTraverse` (`.modifyA`), `Applicative → ForgetfulApplicative` (`.put`), `Monad → + AssociativeFunctor` (same-carrier `.andThen`). **Deliberately lacks `Accessor`/`ReverseAccessor`** + — a `Forget[F]` optic has no `.get`/`.reverse`. This is why the recursive schemes return `Direct` + carriers, and `Forget[F]` is only the home of the single-*layer* project/embed optic. +- **`schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala`** — the path being complemented. + `cata` = `Getter` via `foldInPlace(Plated.childrenArray, alg)`; `ana` = `Review` via + `unfoldCoalg`; `hylo` = fused `Getter` via `unfoldFold`. All on a 512-deep on-stack / `ArrayDeque` + heap-fallback hybrid. The typed `cataF/anaF/hyloF` are added to **this same `Schemes` object** for + discoverability next to their `PSVec` counterparts. +- **`core/.../optics/Plated.scala`** — `rewrite` (lines ~191) is the **`cats.Eval`-trampolined** + precedent (`Eval.defer` + `plate.modifyA[Eval]`); the typed driver mirrors *this* pattern, not the + explicit `ArrayDeque` machine (see Key Decisions for why the engine choice differs from #23). +- **`core/.../optics/{Getter,Review,Fold}.scala`** — read-only optics use the honest `B = Unit` + convention; `cataF`/`hyloF` return `DirectGetter[S, A]`, `anaF` returns `Review[S, Seed]`, exactly + as #23. +- **`schemes/src/test/scala/dev/constructive/eo/schemes/samples/`** — top-level sample ADTs (the + generics macro's outer-accessor rule forbids nesting them in the spec). New `Bin`/`BinF` sample + lands here. +- **droste** (benchmark baseline, already a `benchmarks` dep from #23) — `Scatter[F,A,S] = S => + Either[A, F[S]]` ≈ `Optic.to`; `Gather[F,S,A] = (A, F[S]) => S` ≈ `Optic.from`. Its basic + `kernel.hylo` is naive recursion (stack-unsafe); its `hyloM` (Traverse + Monad) is the stack-safe + shape this plan's `Eval` driver matches. + +### Institutional Learnings + +- **`docs/research/2026-06-08-corecursion-encoding-spike.md`** — *governing verdict.* Encoding B (this + feature) must be "a thin opt-in typed layer over A, not a second engine," justified by + stack-safety + optic-composability. Honored throughout. +- **MEMORY `verify-stacksafety-claims`** — stack-safety must be **tested empirically**, never + asserted. The 10⁶ bar (R2) is a real test (U4), not a claim. +- **MEMORY `bench-box-too-noisy-for-timing`** — local JMH ns/op is ±15–50%; **trust B/op**, run JMH + via `java` not sbt. The driver-mechanism decision (Eval vs heap) is a **B/op** call (U6), not a + local-ns call. +- **MEMORY `eo-schemes-slower-than-droste`** — #23's per-node `Frame`/array + `childrenVec` + allocation already makes eo ~15–20× slower than droste/hand on this box. An `Eval`-node-per-node + driver **compounds** that; the B/op parity bar is non-trivial, and the heap-machine fallback + (deferred) exists precisely for this risk. +- **MEMORY `read-only-optics-should-have-b-unit`** — the `fLayer` project/embed optic is read+write + (an `S ≅ F[S]` one-layer iso worn as `Forget[F]`), **not** read-only; do not give it `B = Unit`. +- **`docs/solutions/2026-04-17-coverage-baseline.md`** — new sources must be reached by the coverage + command. New code is under `schemes/`, already covered by the existing `schemes/test` call — no + coverage-command change needed. + +### External References + +- droste `Basis`/`Project`/`Embed`/`Scatter`/`Gather` (the `Project`/`Embed` type-class names and + the para-flavored gather shape follow droste's vocabulary deliberately). + +## Key Technical Decisions + +- **Schemes return `Direct` carriers; `Forget[F]` is the single-layer home.** `cataF`/`hyloF` return + `DirectGetter`, `anaF` returns `Review` — identical to #23 — so they compose via `andThen`/`cross` + with **zero new core carrier instances** (resolves origin R7's deferred question). `Forget[F]` is + used only for the `fLayer` one-layer project/embed optic (R1's concrete realization of the + spike's G2), where the existing capability ladder already supplies everything obtainable. +- **`Traverse[F]` is required, sharpening R6's "Functor[F]".** A generic *stack-safe* driver must + extract children, fold them under a trampoline, and rebuild the layer. `Functor[F]` alone forces + naive recursion (droste's stack-unsafe basic path). `Traverse[F]` + `Eval` is the lawful + stack-safe primitive. So the honest user obligation is `Traverse[F]` (which implies `Functor[F]`). + Because the deep recursion is driven by the array machine (below) and `Traverse[F]` is used only + *per layer* (bounded fanout), **any lawful `Traverse[F]` works** — stack-safety does not depend on + the user's `foldRight` being `Eval`-lazy. +- **Driver mechanism: the explicit typed heap machine (`foldLayered`) — shipped, not `Eval`.** + *(Updated during implementation — see the resolved Open Question and `site/docs/benchmarks.md`.)* + v1 first shipped a `cats.Eval` trampoline (simplest), but U6's `-prof gc` showed it cost ~8–16× + droste basic (~316 B/node of `Eval` machinery). It was replaced with the pre-planned explicit + machine: the **same `< 512`-on-stack / heap-`ArrayDeque` hybrid as #23's `PSVec` engines**, but + keeping `F` typed at the algebra seam — `foldLeft` reads a node's children into a per-node array + (reused as the result accumulator, folded in place), the deep recursion runs on the machine, and + `map` rebuilds the typed `F[result]` for the algebra (leaf layers skip the rebuild via a phantom + recast). That cut allocation ~7× to **~1.1–2.2× droste basic** (hylo at parity), stack-safe to + 10⁶ in the default heap (no `Eval`, no test fork). The residual `cata` gap is the inherent + native-`Bin`-vs-`Fix` cost (eo `project`s a layer per node; droste's `unfix` is free), the same + cost eo's `PSVec` `cata` pays. +- **Everything in `cats-eo-schemes`, hand-written instances.** Per the chosen scope: no derive macro + in v1, so `Project`/`Embed` type classes live in `schemes/` (not `core/`), no `generics` Compile + dep, no module add, no CI regen. The derive macro (feasible — it generalizes `PlateMacro`) is a + clean follow-up that would later promote the type classes to `core/`. +- **Typed hylo law as the correctness anchor — stated carefully.** The fused-equals-materializing + law `hyloF(coalg, alg).get(seed) == anaF(coalg).cross(cataF(gather)).get(seed)` holds **as a + `forAll` law only for the *pure* algebra** (`F[A] => A`, first argument ignored). For the + para-flavored `(node, F[A]) => A`, `hyloF` threads the **seed** at each layer while `cataF` (after + `anaF` materializes the tree) threads the rebuilt **`S = embed(...)`** — so for a gather that + *reads* its first argument the two diverge unless `alg` and `gather` agree on the + seed↔`embed(coalg(seed))` correspondence. #23's existing hylo-law test sidesteps this with + hand-tuned functions that coincide at one point; this plan instead tests the **pure** flavor + generically via `forAll` and the **para** flavor at specific points (U4). The law depends on the + Project/Embed coherence laws (`embed(project(s)) == s`, `project(embed(fs)) == fs`), also tested. + +## Open Questions + +### Resolved During Planning + +- **Carrier instances for composition (origin R7):** None needed. Schemes are `Direct`-carried; the + `Forget[F]` layer optic uses only existing ladder instances. +- **`Functor[F]` vs `Traverse[F]` (origin R6):** `Traverse[F]` required (see Key Decisions). +- **Module placement / derivation (origin R6):** Hand-written instances in `schemes/`; derive macro + deferred to a follow-up PR (user decision, 2026-06-09). +- **Does `hyloF` need `Project`/`Embed`?** No — it threads `F` directly (`coalg: Seed => F[Seed]`, + `alg: (Seed, F[A]) => A`), needing only `Traverse[F]`. `cataF` needs `Project[F, S]`; `anaF` needs + `Embed[F, S]`. + +### Resolved During Implementation + +- **Eval vs explicit heap machine (origin R2, B/op-gated): RESOLVED — the explicit heap machine + ships.** U6's `-prof gc` first showed the `Eval` trampoline at **~8–16× droste basic** B/op (~316 + B/node of `Eval` machinery). Per the B/op gate, the driver was replaced with the pre-planned + explicit typed heap machine (`foldLayered` — the `< 512`-on-stack / heap-`ArrayDeque` hybrid, `F` + kept typed at the algebra seam). That cut allocation ~7× to **~1.1–2.2× droste basic** (`cata` + 2.2×, `hylo` 1.1× = parity, `ana` 1.6×, now even beating eo's own `PSVec` `ana`) — typed, + stack-safe to 10⁶, no `Eval`, no test fork. No follow-up needed for parity. Table + + rationale: `site/docs/benchmarks.md`. +- **Whether `Eval` reaches 10⁶ cleanly: RESOLVED — yes.** All three (`cataF`/`anaF`/`hyloF`) fold/ + build a 10⁶-deep spine without `StackOverflowError` (U4). `anaF` (the OOM frontier) needs ~1 GB at + 10⁶, so the module forks its tests with `-Xmx2g`. +- **Whether `Eval` reaches 10⁶ cleanly** (the *typed `Eval`* spike verified 10⁵; #23's distinct + `PSVec` engine reached 10⁶; #23's own `ana` stack-safety test only goes to 100k). `anaF` is the + least-proven path — it materializes an O(nodes) `S` *and* holds the `Eval` chain simultaneously, so + its risk at 10⁶ is **OOM/heap-pressure**, not `StackOverflowError`. Expected to pass (Eval is a + heap trampoline), but U4 confirms empirically under a bounded heap; a miss escalates to the + heap-machine fallback. +- **Exact `Project`/`Embed` type-class shape** (two single-method traits vs a combined `Basis[F, S]` + convenience) — settle when writing U1; the methods are fixed (`project`, `embed`). +- **One shared parameterised `Eval`-driver helper vs three per-scheme helpers** — affects duplication + vs clarity; settle when writing U2. +- **Degenerate (non-para) gather ergonomics** — provide a pure `F[A] => A` overload, or expect users + to write `(_, fa) => …` ignoring the node? Settle when writing U2/U5. + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation +> specification. The implementing agent should treat it as context, not code to reproduce.* + +The single-layer optic (R1) — `project`/`embed` worn as the existing `Forget[F]` carrier: + +``` +// to = project (S => F[S]); from = embed (F[S] => S); Forget[F][X,A] = F[A] +fLayer[F[_], S](using Project[F,S], Embed[F,S]): Optic[S, S, S, S, Forget[F]] +``` + +The recursive drivers — `Eval`-trampolined over `Traverse[F]` (mirrors `Plated.rewrite`): + +``` +// cataF: fold S to A. gather = (node: S, folded_children: F[A]) => result: A +// (node FIRST — distinct from droste's dual Gather `(A, F[S]) => S` where the result is first) +cataF[F[_]: Traverse, S, A](gather: (S, F[A]) => A)(using P: Project[F, S]): DirectGetter[S, A] + go(s) : Eval[A] = + Traverse[F].traverse(P.project(s))(child => Eval.defer(go(child))) // Eval[F[A]] + .map(folded => gather(s, folded)) // user PATTERN-MATCHES F[A]'s named ctors (typed!) + Getter(s => go(s).value) + +// anaF: build S from a seed (materializing), embed assembles each typed layer +anaF[F[_]: Traverse, S, Seed](coalg: Seed => F[Seed])(using E: Embed[F, S]): Review[S, Seed] + go(seed) : Eval[S] = + Traverse[F].traverse(coalg(seed))(child => Eval.defer(go(child))).map(E.embed) + Review(seed => go(seed).value) + +// hyloF: fused refold, NO intermediate S, NO Project/Embed — F threaded directly +hyloF[F[_]: Traverse, Seed, A](coalg: Seed => F[Seed], alg: (Seed, F[A]) => A): DirectGetter[Seed, A] + go(seed) : Eval[A] = + Traverse[F].traverse(coalg(seed))(child => Eval.defer(go(child))).map(fa => alg(seed, fa)) + Getter(seed => go(seed).value) +``` + +The type-safety win (R3): `gather`/`alg` receive a typed `F[A]` and destructure by constructor — +`case (_, BranchF(l, r)) => l + r` — where `l, r: A` are named, not `kids(0)/kids(1): AnyRef`. + +## Implementation Units + +```mermaid +graph TB + U1[U1: Project/Embed type classes] --> U2[U2: cataF/anaF/hyloF + fLayer driver] + U1 --> U3[U3: BinF sample + instances test fixtures] + U2 --> U4[U4: behaviour + laws + 10^6 stack-safety tests] + U3 --> U4 + U2 --> U5[U5: mdoc typed-F docs section] + U2 --> U6[U6: JMH bench vs droste basic - optional/splittable] +``` + +- [ ] **Unit 1: `Project[F, S]` / `Embed[F, S]` type classes** + +**Goal:** Define the two hand-written instances the typed path is built on. + +**Requirements:** R1, R6. + +**Dependencies:** None. + +**Files:** +- Create: `schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala` + +**Approach:** +- `trait Project[F[_], S] { def project(s: S): F[S] }` and `trait Embed[F[_], S] { def embed(fs: + F[S]): S }`. Optionally a combined `Basis[F, S] extends Project[F, S] with Embed[F, S]` convenience + (decide at implementation; keep `project`/`embed` as the fixed method names). +- Pure definitions, no instances shipped (the user/tests supply them). Scaladoc states the coherence + laws (`embed(project(s)) == s`, `project(embed(fs)) == fs`) that U4 tests. + +**Patterns to follow:** droste `Project`/`Embed` naming; eo's single-method type-class style (e.g. +`core/.../Accessors.scala`). + +**Test scenarios:** `Test expectation: none -- pure type-class definitions; exercised via U3/U4.` + +**Verification:** `schemes` compiles with the new file; no other module affected. + +- [ ] **Unit 2: `cataF` / `anaF` / `hyloF` + `fLayer` (the `Eval` driver)** + +**Goal:** The feature — typed, stack-safe, optic-returning recursion schemes. + +**Requirements:** R1, R2, R3, R5, R7. + +**Dependencies:** U1. + +**Files:** +- Modify: `schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala` (add `cataF`/`anaF`/`hyloF`/`fLayer` to the existing `Schemes` object + private `Eval` driver helpers) + +**Approach:** +- Add the three public methods + `fLayer` per the High-Level Technical Design. Private `Eval`-driver + helpers (`Eval.defer` + `Traverse[F].traverse`), one per scheme (or one shared parameterised + helper). `cataF` requires `Project[F, S]`; `anaF` requires `Embed[F, S]`; `hyloF` requires neither; + all require `Traverse[F]`. +- `fLayer` constructs an anonymous `Optic[S, S, S, S, Forget[F]]` with `to = project`, `from = embed` + — the concrete realization of spike G2 (R1). Read+write (not `B = Unit`). +- Leave #23's `cata`/`ana`/`hylo` and all `PSVec` engines **untouched** (R4). + +**Execution note:** Implement the driver, then immediately drive U4's 10⁶ stack-safety test against +it — do not declare stack-safety until the test passes. + +**Technical design:** see High-Level Technical Design (directional). + +**Patterns to follow:** `Plated.rewrite` (the `Eval.defer` trampoline); #23's `Schemes` method +shapes and Scaladoc tone; `Getter`/`Review` constructors. + +**Test scenarios:** *(behaviour proven in U4; this unit ships the implementation)* +- Happy path: `cataF` over `BinF`/`Bin` sums leaves; `anaF` builds a `Bin` from an `Int` seed; + `hyloF` computes leaf-count fused. (Asserted in U4.) +- Edge case: a leaf node (`F` with no recursive positions) — `traverse` visits no children, gather + sees the empty-of-children typed layer. (Asserted in U4.) + +**Verification:** `schemes` compiles; `cataF`/`anaF`/`hyloF`/`fLayer` are callable with a +user-supplied `F` + `Traverse[F]` + `Project`/`Embed`; #23 API unchanged. + +- [ ] **Unit 3: Sample pattern functor + hand-written instances (test fixtures)** + +**Goal:** A representative typed `F` to exercise the schemes — grounded in a real recursive ADT. + +**Requirements:** R3, R6. + +**Dependencies:** U1. + +**Files:** +- Create: `schemes/src/test/scala/dev/constructive/eo/schemes/samples/Bin.scala` (top-level `Bin` + recursive ADT + `BinF[_]` pattern functor + `given Traverse[BinF]`, `given Project[BinF, Bin]`, + `given Embed[BinF, Bin]`) + +**Approach:** +- `enum Bin { case Leaf(n: Int); case Branch(l: Bin, r: Bin) }` and `enum BinF[A] { case LeafF(n: + Int); case BranchF(l: A, r: A) }`. **Hand-write `Traverse[BinF]`** — cats 2.13 ships no automatic + `Traverse` derivation for a Scala-3 `enum`, and its `foldRight` must be **`Eval`-based** to stay + lazy (the driver's stack-safety depends on it). Then `Project[BinF, Bin]` (`Branch(l,r) => + BranchF(l,r)`; `Leaf(n) => LeafF(n)`), `Embed[BinF, Bin]` (inverse). Keep **top-level** + (outer-accessor rule). +- Add a second, **wide-and-deep** shape — an N-ary `RoseF[A]` (e.g. `case NodeF(label: Int, kids: + List[A])`) with `Bin`-style `Project`/`Embed`/`Traverse` — so U4 can exercise the + high-fanout-*and*-deep case that a binary spine alone won't (guards the `Traverse`-instance + failure mode in Key Technical Decisions). This is now in-scope (not optional) because it covers a + distinct stack-safety risk. + +**Patterns to follow:** #23's `schemes/.../samples/` top-level ADTs; droste's `Basis` examples. + +**Test scenarios:** `Test expectation: none -- test fixtures; behaviour asserted in U4.` + +**Verification:** `schemes/test` compiles with the fixtures; instances resolve. + +- [ ] **Unit 4: Behaviour + laws + stack-safety tests** + +**Goal:** Prove typed correctness, the 10⁶ stack-safety bar, the coherence + hylo laws, composition, +and the cross-path equivalence to #23. + +**Requirements:** R1, R2, R3, R4, R5, R7 (+ all success criteria). + +**Dependencies:** U2, U3. + +**Files:** +- Create: `schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala` (behaviour) +- Create: `schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala` (ScalaCheck laws) + +**Approach:** specs2 + ScalaCheck, mirroring #23's `SchemesSpec`/`SchemesLawsSpec`. + +**Execution note:** Write the 10⁶ stack-safety test to actually run and pass — empirical, not +asserted (per `verify-stacksafety-claims`). A failure escalates to the deferred heap-machine driver. + +**Patterns to follow:** #23's `SchemesSpec` (behaviour) and `SchemesLawsSpec` (ScalaCheck hylo law). + +**Test scenarios:** +- *Happy path* — `cataF` typed gather sums all leaf values of a `Bin`; `anaF` builds the expected + `Bin` from a seed; `hyloF` fused computes leaf-count == `cataF` over the built tree. +- *Edge case* — single `Leaf` (no recursion); a `Branch(Leaf, Leaf)` (depth 1); empty-of-children + layer handled. +- *Stack/space-safety (R2)* — `cataF`, `anaF`, and `hyloF` each on a **10⁶**-deep left-nested + `Bin`/seed spine complete without `StackOverflowError`, **run under a bounded heap** (a modest + `-Xmx`, e.g. via a JVM fork option for these cases) so a pass certifies O(depth) *space-safety*, + not merely a trampolined call stack; assert completion within a generous wall-clock bound (guards + the known ~15–20× slowdown). Treat the **`anaF` 10⁶** run as the OOM frontier (it holds the `Eval` + chain *and* the materialized `S`). +- *Wide-and-deep stack-safety* — `cataF`/`hyloF` on a `RoseF` that is **both** high-fanout (long + `kids` lists) and deep, to exercise the `Traverse`-instance sequencing path the binary spine + doesn't. +- *Type-safety (R3)* — the gather/coalg destructure `BinF`'s named constructors (`case BranchF(l, r) + => l + r`); include a brief comment/compile-time note that a wrong-arity match is a compile error + (no `kids(2)` runtime path exists). Optionally a `compileErrors`/`typecheck`-style negative check. +- *Coherence laws (ScalaCheck)* — `forAll(s: Bin)`: `embed(project(s)) == s`; `forAll(fs: BinF[Bin])`: + `project(embed(fs)) == fs`. +- *Typed hylo law — pure flavor (ScalaCheck)* — with a **pure** algebra (`alg`/`gather` ignore the + node argument), `forAll(seed)`: `hyloF(coalg, alg).get(seed) == + anaF(coalg).cross(cataF(gather)).get(seed)` (fused == materializing). This is the generically-valid + law. +- *Typed hylo law — para flavor (point tests)* — for a para-flavored `alg`/`gather` that *reads* the + node, assert equality at **specific** seeds where the seed↔`embed(coalg(seed))` correspondence is + arranged to hold (mirroring #23's approach), **not** via arbitrary `forAll` — document why the + generic `forAll` does not apply to the para flavor (the first arguments differ: seed vs rebuilt + `S`). +- *Cross-path equivalence* — `cataF` over `BinF`/`Bin` equals #23's `Schemes.cata` with a + hand-written `Plated[Bin]` on the same algebra (bridges the typed and `PSVec` paths). +- *Composition (R7)* — `Getter[Wrapper, Bin](_.bin).andThen(cataF(...))` reads through; the + materializing `anaF(...).cross(cataF(...))` type-checks and computes. +- *fLayer (R1)* — `fLayer[BinF, Bin]` is a usable `Optic[Bin, Bin, Bin, Bin, Forget[BinF]]`: + `to`/`from` round-trip one layer (`from(to(b)) == b`), and (given `Foldable[BinF]`) `.foldMap` + reads the layer's foci. +- *Regression (R4)* — existing `SchemesSpec`/`SchemesLawsSpec` remain green (run the module suite). + +**Verification:** `schemes/test` green, including the 10⁶ cases; all laws hold under ScalaCheck; +#23's suites unchanged and passing. + +- [ ] **Unit 5: mdoc docs — typed-F section** + +**Goal:** Document the opt-in typed path next to the existing schemes docs. + +**Requirements:** R5, R6 (user obligations), R8 (scope). + +**Dependencies:** U2. + +**Files:** +- Modify: `site/docs/schemes.md` (add a "Typed pattern-functor schemes (`cataF`/`anaF`/`hyloF`)" + section; mdoc-compiled) + +**Approach:** +- Show the `BinF`/`Bin` sample, the hand-written `Traverse`/`Project`/`Embed`, then `cataF`/`anaF`/ + `hyloF` with `mdoc` output. State plainly: typed (named constructors) + stack-safe + composable; + user writes `F` + `Traverse[F]` + `Project`/`Embed`; derivation is future work; this **complements** + the default `PSVec` schemes (when to reach for which). Update the existing "exploratory / type + safety" caveat at the top to point at this typed path as the type-safe option. + +**Patterns to follow:** the existing `site/docs/schemes.md` mdoc style (`mdoc:silent` setup + +`mdoc` eval blocks). + +**Test scenarios:** `Test expectation: none -- mdoc compiles the snippets (build-time check).` + +**Verification:** `docs/mdoc` succeeds (the pre-commit gate), 0 errors; snippets render expected +output. + +- [x] **Unit 6: JMH benchmark — typed-F vs droste basic** ✅ done (in this PR) + +**Result:** eoF `cataF`/`hyloF`/`anaF` rows added to `SchemesBench`; `-prof gc` shows the `Eval` +path at ~8–16× droste-basic B/op — misses parity, so the heap-machine driver is a tracked follow-up. +See `site/docs/benchmarks.md` and the resolved Open Question above. + +**Goal:** Measure B/op against droste's basic schemes (the success-criterion bar) and decide the +driver mechanism. + +**Requirements:** Success criterion (allocation parity). + +**Dependencies:** U2. + +**Files:** +- Modify: `benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala` (+ fixtures) — add + eo `cataF`/`hyloF` rows alongside the existing eo `PSVec` / droste / hand rows. + +**Approach:** +- Add paired benchmark methods for `cataF`/`hyloF` over `BinF` vs droste basic `cata`/`hylo` on the + same 2¹² leaf-sum tree. Run `-prof gc`, **via `java` not sbt**, B/op the signal. Record the result + and the Eval-vs-heap decision in a benchmark-docs note (as #23 did). If `Eval` misses droste-basic + parity, file the heap-machine fallback as the follow-up (do not block v1). + +**Execution note:** Splittable into a follow-up PR if the core feature (U1–U5) is ready first; +trusts B/op, not local ns (`bench-box-too-noisy-for-timing`). + +**Patterns to follow:** #23's `SchemesBench` (paired `eo*`/`droste*`/`hand*` methods, JMH annotations). + +**Test scenarios:** `Test expectation: none -- benchmarks are not part of test; verified by a clean +JMH run producing B/op numbers.` + +**Verification:** `benchmarks` compiles; a smoke `Jmh/run -i 1 -wi 1 -f 1 .*cataF.*` produces numbers; +B/op recorded against droste basic. + +## System-Wide Impact + +- **Interaction graph:** Additive — new methods on the `Schemes` object + one new `schemes/` source + file + test fixtures/specs. No existing call sites change. `core`, `generics`, `circe`, `avro`, + `jsoniter` untouched. +- **Error propagation:** A non-terminating `coalg`/`project` exhausts the heap (Eval thunks) → + `OutOfMemoryError`, mirroring #23's heap-machine behaviour for non-terminating `expand`. Document + in Scaladoc, consistent with #23. +- **State lifecycle risks:** None — pure functions, no persistence, no shared mutable state (the + `Eval` driver allocates per-call thunks, no caches). +- **API surface parity:** The typed path mirrors the `PSVec` path's three entry points + (`cata↔cataF`, `ana↔anaF`, `hylo↔hyloF`) on the same object — discoverability parity. +- **Integration coverage:** U4's composition + cross-path-equivalence + fLayer tests exercise the + real optic-algebra seam (not mocks): `andThen`, `cross`, and the `Forget[F]` carrier ladder. +- **Unchanged invariants:** #23's `cata`/`ana`/`hylo`, all `PSVec` engines, the `Optic` trait, and + every core carrier instance are explicitly **not** changed (R4). The typed path adds no core + carrier instances; it rides `Direct` (schemes) and the existing `Forget[F]` ladder (fLayer). + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| `Eval`-per-node allocation misses droste-basic B/op parity (known: eo schemes already ~15–20× droste) | U6 measures B/op explicitly; deferred explicit-typed-heap-machine fallback is pre-planned and non-blocking; success bar is *basic* droste (also non-specialized), and eo additionally delivers stack-safety droste-basic lacks | +| `Eval` doesn't reach 10⁶ — and `anaF` is the OOM frontier (it holds the Eval chain *and* an O(nodes) materialized `S`); the typed `Eval` spike only verified 10⁵ and #23's `ana` test only 100k | U4 tests 10⁶ empirically **under a bounded heap** (certifies space-safety, not just trampolined stack), with a wall-clock bound; `anaF` tested explicitly; a miss escalates to the heap-machine fallback | +| Hand-written `Traverse[F]` that is naively recursive / non-`Eval`-lazy reintroduces stack growth a binary-spine test misses | Key Decisions states the sequencing obligation; U4 adds a wide-and-deep `RoseF` test; docs recommend deriving `Traverse[F]` | +| `Traverse[F]` burden surprises users expecting just `Functor[F]` (origin R6 said Functor) | Documented as a resolved decision + shown in U5 docs + the `BinF` sample provides a copyable `Traverse` instance; it's exactly droste's stack-safe obligation | +| Hand-written `Project`/`Embed` can violate coherence (silently wrong schemes) | U4 ScalaCheck coherence laws (`embed∘project == id`, `project∘embed == id`) catch incoherent instances; Scaladoc states the laws | +| Scope creep toward deriving `F` or `Project`/`Embed` | Explicit scope boundary; derivation is a named follow-up PR, feasibility already assessed (generalizes `PlateMacro`) | + +## Documentation / Operational Notes + +- `site/docs/schemes.md` typed-F section (U5); update the top-of-page "type safety" caveat to point + at the typed path. No runtime/ops surface (pure library). +- Coverage: new sources are under `schemes/`, already in the `schemes/test` coverage call — no + coverage-command change. +- Pre-commit gate runs `scalafmtCheckAll` + `mdoc` + `laikaSite`; pre-push runs `sbt test`. No + module/CI changes (no `githubWorkflowGenerate`). + +## Alternative Approaches Considered + +- **Wrap droste's stack-safe schemes instead of re-implementing the driver.** droste already ships + `cataM`/`anaM`/`hyloM[Eval]` (Traverse + Monad) which *are* stack-safe — so eo's stack-safety + differentiator is over droste's *basic* `kernel.hylo`, not droste wholesale. A ~thin + `Getter(s => droste.scheme.cataM[Eval](alg).apply(s).value)` would deliver both differentiators + (stack-safe + optic-composable) with near-zero engine code. **Rejected** because the origin + brainstorm settled that **droste is a benchmark baseline, not a runtime dependency** (see origin: + Dependencies / Assumptions) — adding droste to the published `cats-eo-schemes` runtime surface is + excluded. Secondary reasons: the para-flavored gather `(S, F[A]) => A` differs from droste's + `Gather`; eo wants optic-native return types and to avoid `Fix[F]`; and not coupling the public API + to droste's evolution. **This no-runtime-droste-dependency constraint is the load-bearing premise + for building rather than wrapping** — if it were relaxed, wrapping would be the cheaper path. +- **Ship `fLayer` + a single demonstrating `cataF`; defer `anaF`/`hyloF`.** The single-layer + `Forget[F]` optic (R1) is the cheap, low-risk deliverable; the recursive driver carries all the + stack-safety / allocation / hylo-law risk. **Rejected** because the origin fixed v1 = + `cataF`/`anaF`/`hyloF` (R8); the recursive driver *is* the feature's reason to exist (automatic + deep recursion with named constructors). Noted so the risk concentration is explicit, not hidden. + +## Sources & References + +- **Origin document:** [docs/brainstorms/2026-06-09-pattern-functor-carrier-requirements.md](docs/brainstorms/2026-06-09-pattern-functor-carrier-requirements.md) +- Governing verdict: [docs/research/2026-06-08-corecursion-encoding-spike.md](docs/research/2026-06-08-corecursion-encoding-spike.md) +- Complemented path (#23): [docs/plans/2026-06-09-001-feat-schemes-module-plan.md](docs/plans/2026-06-09-001-feat-schemes-module-plan.md) — merged PR #23 +- Related code: `core/.../data/Forget.scala`, `core/.../optics/Plated.scala` (`rewrite`), + `schemes/.../Schemes.scala`, `schemes/.../samples/`, `benchmarks/.../SchemesBench.scala` +- Baseline: droste `Basis`/`Project`/`Embed`/`Scatter`/`Gather`, `kernel.hylo`/`hyloM` +- **Full bibliography (post-merge):** [docs/research/2026-06-15-typed-schemes-bibliography.md](../research/2026-06-15-typed-schemes-bibliography.md) diff --git a/docs/plans/2026-06-11-001-feat-biaffine-scheme-zoo-plan.md b/docs/plans/2026-06-11-001-feat-biaffine-scheme-zoo-plan.md new file mode 100644 index 00000000..b2eef259 --- /dev/null +++ b/docs/plans/2026-06-11-001-feat-biaffine-scheme-zoo-plan.md @@ -0,0 +1,451 @@ +--- +title: "feat: BiAffine carrier + the typed recursion-scheme zoo as optics (para/apo/histo/futu, M-generic driver)" +type: feat +status: implemented (CI bench numbers + code review pending) +date: 2026-06-11 +origin: docs/brainstorms/2026-06-08-recursion-schemes-in-eo-requirements.md +grows: PR #24 (feat/typed-recursion-schemes) +revised: 2026-06-11 (v2 — schemes are optic values, decorations are BiAffine optics, M-generic driver; free-range gcataF/ganaF dropped) +--- + +# feat: BiAffine carrier + the typed recursion-scheme zoo as optics + +## Overview + +Grow PR #24 (`cataF`/`anaF`/`hyloF`) into the **wider recursion-scheme zoo** — para, +apo, histo, futu — built on three structural commitments (v2): + +1. **Decorations are optics.** A generalized scheme's gather/scatter pair *is* an + optic in eo's own encoding: `scatter: W => Either[A, F[W]]` is an affine match + whose leftover is one F-layer; `gather: (A, F[W]) => W` is the product build that + consumes it. Together: `Optic[W, W, A, A, BiAffine]` with existential `X` a + `Tuple2` (so `Fst`/`Snd` reduce, as with `Affine`): `Snd[X] = F[W]` — the + one-F-layer leftover — **uniformly**; `Fst[X]` — the `Done` payload — **pinned + per value** (apo: a finished `S`; futu: a prebuilt layer `F[W]`). + Members inhabit the family the way `Fold`/`Review` inhabit the optic lattice: + fold-side decorations (para/histo) are **build-only** members (gather = `from`, + read side Unit-pinned), unfold-side (apo/futu) are **read-only** members + (scatter = `to`) — laws bind the inhabited side. The zoo is a **vocabulary of + named decoration values** of this one family — not free-range `gcataF`/`ganaF` + methods (dropped). +2. **Schemes are optic values with fusion semantics.** `cataF`/`anaF` return concrete + optic classes that *carry their (co)algebra and decoration as data*, so + `anaF(coalg).cross(cataF(alg))` **fuses to hylo** — deforestation as composition, + on the seam core already names for it (`Optic.cross`'s scaladoc: "the motivating + case is `ana.cross(cata)`"), implemented as a fused overload in the `Unfold` + (#26)/`DirectGetter` style. On the M path fusion IS a fused `andThen` — there the + seam genuinely is focus→source (`Forget[M]` Kleisli). `hyloF` remains as the name + for the fused result (and the always-fused spelling), not a third independent + driver. + + > **Refined (2026-06-13).** The `.cross` framing here is *correct* and stays — the + > smell to fix was the `Cata`/`Ana`/`CataM`/`AnaM` *clone classes* (with `asGetter`/ + > `asReview`), not the directions. Resolution: `cata` returns the real core + > `Getter[S, A]` and `ana`/`apo`/`futu` the real core `Review[S, Seed]` (the + > Getter↔Review duality — "if cata is a Getter, ana is a Review"); the clone classes + > are deleted. The materialising refold is `ana.cross(cata) : Getter[Seed, A]` (the + > build⇄read seam `Optic.cross` names); `Schemes.hylo` stays the fused + > (no-intermediate-`S`) spelling. Empirical (`-prof gc`): `ana.cross(cata)` 885k B/op + > == manual `cata.get(ana.reverseGet())` 885k; fused `hylo` 361k (unchanged). M rung: + > the effect only fits `Forget[M]`'s Kleisli *read* slot, so both `cataM` and `anaM` + > are `FoldM`s (`Seed => M[S]` is a Kleisli arrow that is *semantically* a build) — + > the read/build duality collapses, composition is `anaM.andThen(cataM)` (concrete + > `FoldM.andThen`, Kleisli `flatMap`), not `cross`. A short-lived earlier attempt to + > make pure `ana` a *forward Getter* (to dodge `cross`) was reverted — it broke the + > duality. +3. **The driver is M-generic.** Computational steps evolve in a `Monad[M]` (the arbo + `Calculator` shape: fetching children is effectful, `GetSellOptions[M, O]`). + Effectful schemes return **`Forget[M]`-carried citizens** (`Seed => M[B]` is a + Fold over `Forget[M]` — an existing carrier with existing composition via + `assocForgetMonad`/`ReadCompose`). The pure citizens are a separate **fused fast + path** (Direct-carried), pinned **extensionally equal** to the `M = Id` driver by + law — agreement, not architectural identity. + `Project`/`Embed` stop being the API surface: the driver takes **layer optics as + arguments** (a `Basis` is one *constructor* of a layer optic, an effectful + `S => M[F[S]]` is another, a circe/Plated layer a third). + +The motivating symmetry stands from v1: every fold-side enrichment is comonadic = +product-shaped, every unfold-side enrichment is monadic = sum-shaped, and eo already +owns both shapes as carriers: + +| scheme | decoration (as a `Decor` value) | shape | W | +|---|---|---|---| +| cata / ana | `Decor.id` | — | `A` | +| **para** | `Decor.para` — child slots carry original subterms | product | `(S, A)` | +| **apo** | `Decor.apo` — child slots may graft a finished subtree | sum | `Either[S, A]` | +| **histo** | `Decor.histo` — full decorated history per child | iterated product | `Attr[F, A]` | +| **futu** | `Decor.futu` — multiple layers per step | iterated sum | `Coattr[F, A]` | +| zygo / dyna / chrono | user-written `Decor` values | — | user's `W` | +| elgot / coelgot / micro | **follow-up** (answer-level sum, `Either[B, F[A]]` outside the layer) | sum | `Either[B, _]` outside `F` | + +"BiAffine" is the carrier this family wears: `Affine`'s data shape where the +miss-branch is a *successful* outcome on the build seam (`Done` = "finished, graft +as-is"), with its own laws (`graft(Done(t)) == t`). Literature check (2026-06-11): +the adjacent cells are named — coalgebraic prism (Clarke et al. 2024, Rem. 3.19), +achromatic lens (Riley §4.10), partial isos (Rendel–Ostermann), fold/unfold lenses +(Pacheco–Cunha) — but **the decoration-as-build-affine-optic cell is unpublished**. +droste has the shape without the name (`Gather`/`Scatter`); eo names it and pins it +with laws. + +## Decisions (settled 2026-06-11, interactive; v2 revisions marked) + +1. **Scope** — recursion schemes only. The failure-typed-build spike + (`docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md`) stays separate. +2. **Encoding** — ~~citizens first, carrier later~~ **(v2)** the `BiAffine` carrier + and the `Decor` optic family are the *foundation*, since the zoo's surface is + built from them. +3. **Zoo scope v1** — para + apo + histo + futu, as named `Decor` values. +4. **Engine** — typed pattern-functor path only. ~~Untyped `Plated`/`PSVec` unchanged~~ **(superseded 2026-06-12, user decision post-review: the untyped `cata`/`ana`/`hylo` were REMOVED — the typed path subsumes them; core `Plated` itself is untouched).** +5. **Decorations** — hand-rolled `Attr`/`Coattr` (droste's model, no cats-free dep). +6. **paraF is explicit** — a named member, not a documentation note. +7. ~~Public free-range `gcataF`/`ganaF`~~ **(v2) dropped.** The generality lives in + the public `Decor` family instead: zygo/dyna/chrono are user-*written* `Decor` + values, not user-called generic methods. Squint test: the gather/scatter pair was + always an optic; now it is one. +8. **Sequencing** — grow PR #24. Rationale (recorded post-review): stage 3 + re-derives #24's own unmerged `cataF`/`anaF`, so one PR avoids publishing a + transient API and re-reviewing the same lines twice; the staging seams below + remain the split points if review load demands it. Split trigger: if review + needs a second full round-trip (or the diff crosses ~3k added lines), stages + 1–4 split out as the foundation PR — author's call at the end of stage 4. +9. **(v2, refined in review) Schemes are optic values with fusion composition** — + pure path: `anaF.cross(cataF)` fuses to hylo (`cross` is core's name for the + build-output→read-input seam); M path: `AnaFM.andThen(CataFM)` fuses (there it + is the focus seam). Collapsing to bare `DirectGetter`/`Review` at construction + is a no-go. +10. **(v2) M-generic driver in v1** — effectful results are `Forget[M]`-carried + citizens; the pure citizens are the Direct-carried fast path, law-pinned + extensionally equal to `M = Id`. +11. **(v2) elgot/coelgot/micro deferred** to a follow-up that also completes the arbo + `Calculator.selection` port. v1's acceptance example is arbo-*shaped* (effectful + children in `M`) but uses cata/ana/hylo decorations only. + +## Problem Frame + +PR #24's typed path has exactly three schemes, all undecorated, all pure, all +collapsing to `DirectGetter`/`Review` at construction — which erases precisely the +structure a real consumer needs. The reference real-world case +(`~/workspace/crypto/arbo/src/main/scala/arbo/Calculator.scala` + +`arbo/elgot/package.scala`) had to hand-roll its scheme family on droste's kernel: +an *effectful* coalgebra (`A => M[Either[B, F[A]]]`, children fetched via +`GetSellOptions[M, O]`), answer-level short-circuit, fused execution, result +`A => M[B]`. Nothing in eo (or droste's public zoo) offers: decorated schemes that +**compose as optics**, an **M-generic** driver, or **fusion by composition** +(`ana.cross(cata) == hylo`). droste's basic schemes are also stack-unsafe and its +`gana`-apo re-walks grafts in O(graft). + +The gap: the zoo as *composable optic values* — decorations, algebras (`Unfold`, +#26), layers (`fLayer`, #24), and assembled schemes all citizens of one algebra — +stack-safe, M-generic, with O(1) graft and hylo-fusion as measurable, law-pinned +differentiators. + +## Design + +### D1. The `BiAffine` carrier + the `Decor` optic family (foundation) + +```scala +/** Affine's data shape worn on the BUILD seam: Done = "the engine does not call the + * coalgebra for this slot". Its payload's meaning is pinned PER VALUE via X: + * apo's Done carries a finished subtree S (prefill the slot, O(1) graft); + * futu's Done carries a prebuilt layer F[W] (unroll it, still no coalgebra call). */ +enum BiAffine[X, +A]: + case Step(snd: Snd[X], a: A) // keep going from a (one-F-layer context alongside) + case Done(fst: Fst[X]) // no coalgebra call — payload interpreted per value +``` + +A **decoration** is an optic of this carrier whose existential leftover is one +F-layer. The family has **typed sub-shapes** — sides are pinned in the *type*, +eo-style, per the read-only-optics convention (pass-2 resolution): + +```scala +/** scatter = to: W => Step(layerCtx, focus) | Done(payload) — affine match + * gather = from: Step(layerCtx, result) => W — product build */ +type DecorGather [F[_], W, A] = Optic[Unit, W, Unit, A, BiAffine] // fold side: gather-only +type DecorScatter[F[_], W, A] = Optic[W, Unit, A, Unit, BiAffine] // unfold side: scatter-only +type Decor [F[_], W, A] = Optic[W, W, A, A, BiAffine] // full citizen (both halves) +// X a Tuple2 per value: Snd[X] = F[W] uniform; Fst[X] = the Done payload +// (apo: S, futu: F[W]) +``` + +para/histo are `DecorGather` values; apo/futu are `DecorScatter` values; a member +carrying both halves is a full `Decor` (the apo+para composite, when someone needs +it). The fold-family constructors accept `DecorGather`, the unfold-family +`DecorScatter` — each driver takes exactly the half it consumes. This is the honest +version of droste's separate `Gather`/`Scatter` types, inside one family. + +Named values: `Decor.id` (W = A), `Decor.para[F, S]` (W = (S, A)), +`Decor.apo[F, S]` (W = Either[S, A]), `Decor.histo[F, A]` (W = Attr[F, A]), +`Decor.futu[F, A]` (W = Coattr[F, A]). zygo/dyna/chrono: user-written values, one +shown in the docs. + +- **No `Optic` trait change** (the `fLayer`/#26 standard). Capabilities: a + `Graft`-style reverse accessor consuming the `Done` channel, instantiated per + value (graft-finished for apo, unroll-layer for futu). +- **Laws (bind the inhabited side only):** `graft(Done(t)) == t` (Done is final — + scatter-side members); gather/scatter round-trip where both sides exist; the + fast-path agreement laws live in D5. The `Done`/`Step`-coherence-across-`andThen` + law **moves to the matrix-row follow-up** — it needs the + `AssociativeFunctor[BiAffine]` instance this PR does not ship. +- **Composition-matrix row (11 → 12): follow-up PR**, not this one. + +### D2. Decoration data: `Attr` / `Coattr` (`schemes/Decor.scala`) + +```scala +final case class Attr[F[_], A](head: A, tail: F[Attr[F, A]]) // cofree, no laziness +enum Coattr[F[_], A]: + case Pure(a: A) // free, no suspension + case Roll(layer: F[Coattr[F, A]]) +``` + +Minimal API; no `Eval` fields. Space honesty: histo is inherently O(n) decorations — +documented, not hidden. + +### D3. Scheme citizens: concrete optic classes, fusion by `andThen`, M-generic + +Constructors keep their names; what they *return* changes — concrete classes that +carry their parts so composition can fuse: + +```scala +// Pure path, still named cataF/anaF/hyloF. DirectGetter/Review are FINAL in core +// (perf-pinned encoding), so the citizens extend the open Optic TRAIT directly — +// zero core changes, full generic composition via the trait members: +final class CataF[F[_], S, A](layer: …, decor: …, alg: …) + extends Optic[S, Unit, A, Unit, Direct] // Getter-shaped, carries its parts +final class AnaF[F[_], Seed, S](layer: …, decor: …, coalg: …) + extends Optic[Unit, S, Unit, Seed, Direct] // Review-shaped + +// THE fusion seam — cross, core's own name for build-output→read-input composition +// (Optic.cross scaladoc: "the motivating case is ana.cross(cata)"): +// anaF(coalg).cross(cataF(alg)) : DirectGetter[Seed, A] — fused, no S built +// as a fused overload on the concrete classes (the valdef-encoding memory says +// exactly this seam regresses 3x if left generic). Widening hazard, documented: +// binding anaF(…) to a wider type loses the fused overload — hyloF(coalg, alg) +// stays as the always-fused spelling. + +// Effectful path: concrete carrying classes here too — the fused overload cannot +// live on the erased trait type (overloads resolve on concrete classes): +final class CataFM[M[_], F[_], S, A](…) // upcasts to Optic[S, Unit, A, Unit, Forget[M]] +final class AnaFM [M[_], F[_], Seed, S](…) // upcasts to Optic[Seed, Unit, S, Unit, Forget[M]] +def cataFM[M[_]: Monad, F[_], S, A](layerM: S => M[F[S]], …): CataFM[M, F, S, A] +def anaFM [M[_]: Monad, F[_], Seed, S](coalgM: Seed => M[F[Seed]], …): AnaFM[M, F, Seed, S] +// AnaFM.andThen(CataFM) fuses via the concrete classes — here andThen is the +// genuine focus seam (Forget[M] Kleisli) — the arbo execution shape (Seed => M[B]). +// +// Consumption: effect Ms (IO, …) have no Foldable, so the Foldable-gated Fold ops +// (.foldMap/.headOption) and ReadCompose cells do NOT apply. The concrete classes +// expose the run surface directly as the public consumption op: +// CataFM.run: S => M[A] AnaFM.run: Seed => M[S] (not raw .to) +// v1 composition scope for Forget[M] citizens: same-carrier andThen +// (assocForgetMonad) + run. An Accessor-into-M capability is follow-up material. +// +// hyloFM(coalgM, algM) is the always-fused M spelling (what D6's eoHyloM row runs). +// Same widening hazard as the pure path: a widened AnaFM still typechecks through +// the generic trait andThen (assocForgetMonad) — extensionally equal but +// MATERIALIZING (M[S] built, then folded). The M fusion law pins the +// concrete-typed spelling only. +``` + +- **Layers are arguments, not implicits.** `Basis`/`Project`/`Embed` become + *constructors* of layer optics (`fLayer` et al.), with overloads defaulting to the + `Basis`-derived layer so the common case stays terse. An effectful layer + (`S => M[F[S]]`) and an integration layer (circe/Plated) plug into the same slot — + this is the "richer usage surface": compose into the layer seam before recursing. +- **Engines:** the pure citizens (`cataF`/`anaF`/`hyloF`) keep the `< 512`-on-stack + / `ArrayDeque` hybrid untouched. `CataFM`/`AnaFM`/`hyloFM` **always run the + foldLayered state machine lifted into M — no `M = Id` special-case** (that is + what makes D5's agreement law a real cross-architecture pin): state = the + explicit frame deque, threaded through `Monad[M].tailRecM`, one iteration per + node event — each paying tailRecM's per-step `Either`, the structural B/op floor + vs the pure machine (acknowledged; benched). NOT droste's `hyloM` + (flatMap-recursive — O(depth) call stack on a strict `M`, the shape this plan + elsewhere criticizes). Stack-safety thus reduces to the lawfulness of M's + `tailRecM` — **per-M and tested, not asserted**: `Id`/`Eval` to 10⁶ + (`CataFM[Eval]`/`AnaFM[Eval]`). **Supported Ms are single-pass and linear** — + the lifted machine threads mutable state (the frame deque, in-place child + arrays), so a branching/replaying `M` (`List`, retrying or streaming effects) + would share that state across branches and corrupt the fold. The contract is + stated in scaladoc + docs; a persistent-state variant is deferred until a real + consumer needs it. +- **Inlining discipline:** concrete classes host no shared per-instance `Function1` + dispatch (use-site-friendly encoding rules); PrintInlining check on the fused hot + path AND the `Decor.id`-routed cata path before merging. + +### D4. The named zoo (decoration values + native engine routes) + +- **para** (`Decor.para`): the machine walks real `S` nodes and keeps each frame's + projected layer, so child slots pair subterms positionally **without droste's + per-node re-`embed`**. Bench-pin. +- **apo** (`Decor.apo`): **native O(1) graft.** `Done(s)` slots are already-finished + results — prefill, never recurse, never project. A `foldLayered` sibling + (`foldLayeredOr`) consumes the BiAffine slot decision directly. droste's + scatter-apo re-walks grafts through `project`; this is the measurable claim. +- **histo / futu** (`Decor.histo` / `Decor.futu`): definitional — the proof the + `Decor` family is correctly shaped. Engine stores `Attr`/`Coattr` in the result + slots. +- Fold-side algebras stay node-supplied (`(S, …) => A`), matching `cataF`. +- **User-written `Decor` values** (zygo/dyna/chrono): the same public constructors + accept any `Decor` value. Named values dispatch to their native engine routes + (identity match); user values run the **generic decoration route**, which pays the + per-node decoration dispatch the native routes avoid — documented honestly, with + a generic-route bench row (D6) as the honesty number. + +### D5. Laws & tests (`SchemesFLawsSpec` / `SchemesFSpec` extensions) + +- **Fusion law (new, central):** `anaF(c).cross(cataF(a)) == hyloF(c, a)`, stated + with the side condition the branch's own hyloF scaladoc records: unconditional + for *pure* algebras (node argument ignored); for node-reading algebras it holds + under the seed↔`embed(coalg(seed))` correspondence — both forms pinned. And + **builds no intermediate `S`** (allocation-pinned via the gc profiler in CI, + B/op). M path: concrete-typed `anaFM(c).andThen(cataFM(a)) == hyloFM(c, a)`, + allocation-pinned — the widened trait-`andThen` spelling is extensionally equal + but materializing, explicitly outside the pin. +- **Degeneration laws:** para ignoring subterms == cata; never-grafting apo == ana; + heads-only histo == cata; single-layer futu == ana. +- **Fast-path agreement laws:** `cataFM[Id](…).run == cataF(…).get` (and ana/hylo + likewise) — extensional equality between the Direct-carried fast path and the + `M = Id` driver, per decision 10. +- *(Spec roles: `SchemesFLawsSpec` hosts the law properties above; + `SchemesFSpec` the engine/behaviour tests — stack-safety, graft identity, the + acceptance example.)* +- **Decoration optic laws:** scatter/gather round-trip per named `Decor` value; + `graft(Done(t)) == t`. +- **O(1) graft observable:** grafted subtree present **by reference** (`eq`) in the + result — the law-shaped perf claim. +- **Stack-safety to 10⁶ tested per driver** (pure machine; the lifted machine via + `CataFM[Eval]`/`AnaFM[Eval]`), deep `Coattr` chains included; histo's O(n) space + documented. +- **Generic-route correctness:** the D7 zygo (a user-written `Decor`) run through + the generic decoration route, pinned against a hand-rolled zygo — the route's + correctness criterion, not just its D6 dispatch-cost number. +- **Linear-M contract:** one test documents a non-linear `M` (`List`) as + unsupported — the mutable-state machine's stated boundary, exercised rather + than implied. +- **Acceptance example (arbo-shaped):** an effectful hylo over a sell-tree-like + fixture whose children arrive in `M` (`GetSellOptions` analogue), result + `Seed => M[B]`, composed via `anaFM.andThen(cataFM)`. The full + `Calculator.selection` port (needs elgot) is the follow-up's acceptance test. + +### D6. Benchmarks (`SchemesBench` additions) + +Paired vs droste on the existing fixtures, B/op primary: `eoParaF`/`dPara`, +`eoApoF`/`dApo`, `eoHistoF`/`dHisto`, `eoFutuF`/`dFutu`, plus `eoHyloM`/`dHyloM` +(effectful driver overhead; pin: `eoHyloM` B/op ≤ `dHyloM` — the per-node-event +`Either` floor is the expected cost, not an excuse) and a fused-vs-materialized +pair pinning the fusion law's allocation claim. Headline pins: para ≤ droste B/op (no re-embed); **apo B/op +independent of graft size** (droste linear); histo/futu parity-or-better; ana's known +2.4× B/op gap (CI 2026-06-11) not worsened by the new surface; **`cataF`/`hyloF` +before/after pin for the `Decor.id` re-derivation** (B/op equal and CI ns within noise +vs the pre-stage-3 baseline — the regression this refactor is most likely to cause); +a **generic-route row** for one user-written `Decor` value (D4's dispatch-cost honesty +number). Pin classes: **merge gates** — the `cataF`/`hyloF` before/after pin, the +ana-gap-not-worsened pin, the fusion no-intermediate-`S` pin; **docs-claim gates** — +para ≤ droste and apo graft-independence (each held to its verification item); +**recorded, not gated** — histo/futu (definitional members, not differentiators). +Two verification items before headlines go in docs: **confirm droste's apo +actually re-walks grafts on the fixtures** (if not, the O(1)-graft claim adjusts to +absolute numbers), and record histo's **peak retained decorations** (analytic count +from the fixture confirmed by one heap-histogram run outside JMH; the number lands +in the docs' space-honesty note). + +### D7. Docs (`site/docs/schemes.md`) + +- Zoo section anchored on the symmetry table; schemes-as-values and fusion-as-`cross` + shown first (the story IS the surface now). Claims scoped to the shipped seams + (cross fusion, M-path `andThen`, layer arguments) — general matrix citizenship + waits for the follow-up row. +- BiAffine narrative: *the decoration optic* — the unpublished cell, adjacent named + cells cited honestly. +- apo example with teeth: patch-one-subtree-keep-the-rest, O(1) graft visible. +- **zygo written by hand as a `Decor` value** — the proof the family replaces the + dropped free-range generics. +- Effectful example: the arbo-shaped fetch-children-in-M hylo. + +## Staging (commits on `feat/typed-recursion-schemes`) + +0. **Rebase `feat/typed-recursion-schemes` onto `main`** — the plan leans on + main-only artifacts the branch predates (Unfold #26's fused-overload precedent, + `ReadCompose`, the accessor/forgetful/compose package split, the def-based + `to`/`from` encoding). Re-run the typed-schemes bench after rebasing to confirm + the `foldLayered` numbers survive; reconcile class names used below against + post-rebase core. +1. `BiAffine` carrier + `Graft` capability + carrier laws (core + laws). +2. `Attr`/`Coattr` + unit tests. +3. `Decor` family + named values (id/para/apo/histo/futu) + decoration laws; + `cataF`/`anaF` re-derived through `Decor.id` — rewritten in place, same public + API (behaviour-identical, tests prove it). +4. Scheme citizens (`CataF`/`AnaF` classes) + **fused `cross` overload** (pure path; + the M-path fused `andThen` lands in stage 5) + fusion laws; `foldLayeredOr` + (O(1) graft) + zoo degeneration laws + stack-safety sweep. Overload-set + discipline per `Getter`'s precedent: re-home trait overloads into the class + where dotty would tie, and pin resolution with a matrix-spec-style ascription + test (`anaF(c).cross(cataF(a))` resolves to the fused overload). +5. M-generic driver (`CataFM`/`AnaFM`, the tailRecM-lifted machine) + `run` surface + + arbo-shaped acceptance example. **Pre-commit gate:** sketch the elgot + `Decor`/driver seam against the v1 signatures (one page, brainstorm note) — + turns decision 11's "no re-architecture" from assertion into check. Fail + action: a public-signature change lands in this stage before the M-driver + commits; if the sketch demands a new driver shape, decision 11 re-opens. +6. `SchemesBench` zoo + hyloM + fusion + generic-route rows; CI numbers (benchmarks + workflow, `-prof gc`); PrintInlining runs per D3 (fused path + `Decor.id` cata path). +7. `site/docs/schemes.md` rewrite per D7; PR #24 description widened. + +## Alternatives considered (rejected) + +- **Free-range `gcataF`/`ganaF` methods** (v1 of this plan): rejected — the + gather/scatter pair is an optic and the surface should say so; generality moves to + the public `Decor` family. +- **`andThen`-spelled pure fusion** (v2 as first written): rejected in review — it + forks `andThen` into two opposite seam semantics on one class; core already names + the build-output→read-input seam `cross` (its scaladoc cites `ana.cross(cata)` as + the motivating case). +- **Collapse to `DirectGetter`/`Review` at construction** (v1): rejected — erases the + structure fusion and effectful composition need; concrete carrying classes instead. +- **`distApo` encoding** (apo via scatter over `Either[S, A]`): O(graft) re-walk; + native `Done`-slot engine instead. +- **cats-free `Cofree`/`Free`**: dependency + `Eval` fields the machine never needs. +- **Failure-channel unification**: out of scope (decision 1). +- **Separate/stacked PR**: rejected (decision 8); #24 grows. + +## Out of scope + +- **elgot / coelgot / micro** (answer-level sum, `Either[B, F[A]]` *outside* the + layer — arbo's exact decoration): explicit **follow-up**, whose acceptance test is + the full arbo `Calculator.selection` port. The `Decor` family and M-driver land + ready for it (the follow-up adds values + one driver seam, no re-architecture). +- Failure-typed build (2026-06-10 brainstorm) — separate spike. +- Untyped `Plated`/`PSVec` path — unchanged. +- Named zygo/dyna/chrono — user-written `Decor` values (one documented). +- BiAffine composition-matrix row (12-family matrix) — follow-up PR. +- Accessor-into-M capability for `Forget[M]` citizens — v1 ships same-carrier + `andThen` + `run` only (D3). +- Persistent-state M-engine for non-linear Ms (`List`, replaying/streaming + effects) — v1's lifted machine is single-pass linear by contract (D3). +- cats-free interop conversions. + +## Open questions (to resolve during implementation) + +- Exact `Decor` existential plumbing: the per-value X pinning (`Snd[X] = F[W]` + uniform, `Fst[X]` per member) as construction-site refinement vs a + type-member-refined family — surface at the spike, as `fLayer` did in #24. +- `foldLayeredOr` vs parameterizing `foldLayered` — whichever keeps the hot cata + path's inlining intact (PrintInlining check). +- How terse the common case stays once layers are arguments (default-overload + ergonomics: `cataF(alg)` with a `Basis` in scope must remain one call). +- Whether `Attr` wants a `Plated` instance — defer unless a test wants it. + +## References + +- **Full bibliography:** [docs/research/2026-06-15-typed-schemes-bibliography.md](../research/2026-06-15-typed-schemes-bibliography.md) + — the anchor paper (O'Connor's Multiplate, arXiv:1103.2841) with its relevant + references and citations, the recursion-schemes canon, and the + Scala-ecosystem implementations, each mapped to this branch's claims. +- arbo (`~/workspace/crypto/arbo`): `Calculator.scala`, `elgot/package.scala` — the + real-world consumer this design must serve (`ElgotCoalgebraM`, `elgotM`, `micro`). +- droste `algebras.scala` / `kernel.scala` (Gather/Scatter, `hyloM`). +- Uustalu–Vene–Pardo, *Recursion schemes from comonads* (2001); Hinze–Wu–Gibbons, + *Unifying structured recursion schemes* (ICFP 2013). +- Clarke et al., *Profunctor Optics: a Categorical Update* (2024) — coalgebraic prism. +- Riley, *Categories of Optics*; §4.10 achromatic variant. +- Yang–Wu, *Fantastic Morphisms and Where to Find Them* (arXiv 2202.13633). +- Kmett, recursion-schemes (`distPara`/`distApo`); *Elgot (Co)Algebras* (2008) — + answer-level vs subtree-level affine, now the explicit v1/follow-up boundary. diff --git a/docs/plans/2026-06-15-001-cleanup-typed-schemes-merge-plan.md b/docs/plans/2026-06-15-001-cleanup-typed-schemes-merge-plan.md new file mode 100644 index 00000000..d1e0069d --- /dev/null +++ b/docs/plans/2026-06-15-001-cleanup-typed-schemes-merge-plan.md @@ -0,0 +1,147 @@ +--- +title: "cleanup: typed recursion schemes merge-readiness (bibliography + rough edges)" +type: cleanup +status: in-progress (C1, C3, C5 done 2026-06-15; C8 found during C1) +date: 2026-06-15 +origin: thread request (kryptt): read the anchor paper, build the bibliography, + plan the cleanup so PR #24 can merge +--- + +# cleanup: typed recursion schemes merge-readiness + +## State of the branch (2026-06-15) + +`feat/typed-recursion-schemes` (49 commits over `origin/main`, PR #24) ships the +typed zoo as existential-indexed optics: `Graft[Affine]` — Affine's arms worn +on the build seam — as the decoration carrier in core, +`Attr`/`Coattr` decorations, the `Schemes` citizens (cata/para/histo/zygo/mutu, +ana/apo/futu/cozygo/comutu, fused hylo/dyna/codyna/chrono/elgot/coelgot, meta/ +metaChrono, prepro/postpro, the M-family), `paraLens`, the `Plated`↔`Basis` +bridge, and `Affine.assoc` carrying the decoration composition row (the draft +`BiAffine` clone was dropped pre-merge per review — the arms are isomorphic). Tests pass on both JDKs; the plan's +stages 1–7 are implemented; the two open brainstorm spikes +(`elgot-seam-sketch` — PASS, `existential-x-is-the-decoration` — substrate +landed, follow-ups listed) are recorded. + +**CI status: Test ✅ / Generate Site ❌ / Cloudflare Pages preview ❌.** The merge +blocker is the docs build, plus a short list of understood rough edges below. + +## The anchor paper (what the branch is anchored to) + +The requested paper, `arXiv:1103.2841`, is **O'Connor, "Functor is to Lens as +Applicative is to Biplate: Introducing Multiplate" (WGP 2011)** — a lens/plate +paper, not a recursion-schemes paper. That is not a mismatch to fix but the +second half of the branch's thesis: it categorically certifies the *optics half* +of "recursion schemes are optics" — + +- lens = coalgebra of the store comonad (§2.2): the formal ground for + `paraLens` (a para's retained subterms are the store's complement); +- biplate = coalgebra of the Cartesian store comonad (§3): the formal ground for + `Plated.plate`/`Schemes.fLayer` (the one-layer typed self-traversal); +- the van-Laarhoven isomorphism (§4, via Wadler's free theorems): the polymorphic + and coalgebraic presentations of the same optic coincide — the branch's + "two readings of the same fact at the X seam" is this theorem in eo's encoding. + +The full bibliography (anchor, its relevant references, its relevant citations, +the recursion-schemes canon, the Scala-ecosystem implementations) is in +[`docs/research/2026-06-15-typed-schemes-bibliography.md`](../research/2026-06-15-typed-schemes-bibliography.md). +The paper the zoo's *schemes* come from is Hinze–Wu–Gibbons' *Unifying +structured recursion schemes* (ICFP 2013) and Uustalu–Vene–Pardo's *Recursion +schemes from comonads* (2001) — both already cited in the plan docs; the +bibliography consolidates them. + +## Cleanup items (merge-blocking first) + +### C1. ✅ DONE (2026-06-15). Fix `site/docs/schemes.md` — it taught a retired API (blocked `docs/mdoc`, CI red) + +`mdoc` reports 10 errors, all API drift between the doc and the shipped surface: + +| lines | doc teaches | shipped reality | +|---|---|---| +| 71–102, 248–251, 278–282, 308–311 | node-supplied algebras `(node, folded) => …` (2-arg lambda) | **1-arg node-blind algebras** `F[A] => A` (the late refactor, `Schemes.scala` scaladocs already correct) | +| 258–282 | user-written zygo via a `zoo.Gather` type, `cata[BinF, Bin, (Int, Int), Int](zygo(...))` (4 type args) | **no `Gather`/`Scatter` public type**; `zygo` is a named constructor: `Schemes.zygo(aux)(alg)` | +| 251 | `Schemes.cata(zooSum)` where `zooSum: (Bin, BinF[Int]) => Int` | same 1-arg fix; `ana.cross(cata)` then typechecks against `DirectGetter` | + +Action: rewrite the four affected sections against the current API (keep every +claim already scoped in D7); re-run `sbt docs/mdoc` until clean; that also turns +the two site workflows green. The `migration-from-monocle.md`/`optics.md` +"Unknown link 'schemes.md'" warnings should disappear with the same fix (the +link target exists; the warnings pre-date and are informational). + +### C2. Point the plan docs' reference section at the bibliography + +`docs/plans/2026-06-11-001`'s References section (and the 2026-06-09-002 plan, if +touched) gets one line pointing at the new bibliography file, so the branch's +citations live in exactly one place. No claims change. + +### C3. ✅ DONE (kryptt's call: delete; recreate when D5 law specs get a module). `schemes-laws/` was an empty untracked directory tree + +`git ls-files schemes-laws` is empty; the directory exists on disk with no +sources. Either (a) delete it, or (b) if the plan's D5 law specs +(`SchemesFLawsSpec`-style discipline suites) were meant to live there, move the +law-heavy specs out of `schemes/src/test` into it as the module skeleton. +Recommendation: (b)-lite — leave `laws/` as the discipline home (it already +hosts the BiAffine/graft laws per commit 07a461fe) and delete `schemes-laws/`; +two law modules is one too many. Needs kryptt's call since the directory is +referenced nowhere. + +### C4. CHANGELOG section for the schemes work + +`CHANGELOG.md` has no mention of the schemes module (the branch changes the +public surface: new `schemes` artifact, new core `Graft[Affine]`/`Basis`). +Add the 0.1.x section entries per the repo's changelog conventions before merge, +so the release notes don't get written from memory later. + +### C5. ✅ DONE with C1. Doc/code contradiction: "referenced nowhere" claims in `schemes.md` + +`schemes.md` says "the named values dispatch to native engine routes" and +describes `Gather/Scatter` as public — both stale vs. the concrete-citizen +design (`zoo/*.scala` classes + `Schemes` constructors). After C1, re-read the +page top-to-bottom as a reviewer would: every sentence must match +`Schemes.scala`/`Machines.scala` scaladocs (the scaladocs are already +consistent — they were updated in the refactor commits; the page was not). + +### C6. PR description refresh + +PR #24's body still describes the U6 Eval-era decisions and the old +`cataF`/`anaF`/`hyloF` names; the branch has since rebased onto main's renamed +surface (`cata`/`ana`/`hylo` typed path) and grown the zoo, `paraLens`, the +M-family re-carrier, and the `Affine`-carried decoration bridges. Rewrite the description as: +thesis (schemes as optics indexed by their existential X), what ships, the +fused-vs-materializing law, benchmark deltas vs droste, and the follow-ups +(elgot port per the PASSed seam sketch; the X-existential spike items; +Affine matrix row). Link the bibliography for reviewers who want the papers. + +### C7. (non-blocking) `benchmarks` numbers in docs + +`site/docs/benchmarks.md` carries the CI-swept numbers; re-run the JMH sweep +once after C1 so the "before/after pin" rows reflect the final merged state +(the plan's merge-gate pins: `cataF`/`hyloF` before/after, ana-gap-not-worsened, +fusion no-intermediate-S). The pins passed in CI at 56685dfa; re-confirm at the +merge candidate commit. + +### C8. (new, found during C1) `Getter.andThen` is 3-way ambiguous for Direct-carried scheme citizens + +`site/docs/schemes.md`'s lens-composition example — `Getter[Doc, Bin](deepTree.get).andThen(cata)` +— fails to compile: for a Direct-carried citizen (`Optic[A, Unit, C, Unit, Direct]`), **three** +`Getter` overloads all apply — `andThenReadAny` (any inner carrier), the re-homed read-only-inner +override (`inner.T = Unit`), and the trait's same-carrier inline (`outer.F = inner.F = Direct`) — +and dotty calls it a tie. The doc now teaches the unambiguous function-composition spelling, but +core should decide: re-home or drop one of the three (the repo's "overload-set discipline" per the +`Getter` precedent), and pin resolution with a spec. Until then, `getter.andThen(schemeCitizen)` +is a compile-error trap for users. + +## Sequencing + +1. C1 (unblocks CI, the only red gate) → 2. C5 (same file, one review) → +3. C2, C4 (mechanical) → 4. C6 (after code review settles) → 5. C3 (one-line +decision, needs kryptt) → 6. C7 (last, at the merge candidate). + +## Explicitly out of scope (already-triaged follow-ups, not merge blockers) + +- elgot/coelgot `Decor` values + `Calculator.selection` port (seam sketch PASSed; + additive follow-up per decision 11). +- The existential-X spike items (para-as-Lens beyond `paraLens`, memoized + refolds, honest hylo X-parameter, Affine matrix row 12→13). +- Persistent-state M-engine for non-linear Ms; Accessor-into-M capability; + cats-free interop. diff --git a/docs/research/2026-06-15-typed-schemes-bibliography.md b/docs/research/2026-06-15-typed-schemes-bibliography.md new file mode 100644 index 00000000..b27a65c2 --- /dev/null +++ b/docs/research/2026-06-15-typed-schemes-bibliography.md @@ -0,0 +1,163 @@ +# Typed recursion schemes × optics — bibliography + +Research artifact for the `feat/typed-recursion-schemes` merge (PR #24). Two jobs: +anchor the branch's design claims in the literature, and fix the reference list the +plan docs point at (the anchor paper URL in the PR thread, `arXiv:1103.2841`, is +**O'Connor's Multiplate paper** — a lens paper, not a recursion-schemes paper — which +is itself the point: it is the categorical charter for the branch's *optics half*). + +Compiled 2026-06-15. Sources: the paper itself, Semantic Scholar (references + +citations of 1103.2841), and the canon the zoo's schemes come from. + +## 0. The anchor paper + +- **Russell O'Connor, "Functor is to Lens as Applicative is to Biplate: Introducing + Multiplate"** (WGP 2011; [arXiv:1103.2841](https://arxiv.org/abs/1103.2841)). + Two categorical characterisations of lenses — coalgebra of the store comonad, and + monoidal natural transformation on a category of coalgebras — generalized to the + Cartesian store comonad (whose coalgebras are Uniplate's **Biplates**) and to + Compos's `compos` type. Proves van Laarhoven's conjecture that the two + generalizations are isomorphic; proposes Multiplate for mutually recursive types + (rank-3 polymorphism + type classes). + + Why this paper is the right anchor for PR #24 despite being "about" plates: + + - **Lens = store-comonad coalgebra** (§2.2) is exactly the reading the branch's + para-as-Lens claim leans on: `paraLens` is lawful as a Lens because para's + retained subterms are the store's "position" complement + (`docs/brainstorms/2026-06-12-existential-x-is-the-decoration.md`, reading 2). + - **Biplate = coalgebra of the Cartesian store comonad** (§3) is the generic + single-layer self-traversal — eo's `Plated.plate` / `Schemes.fLayer` on the + `MultiFocus` carrier, one layer of `F[S]` children plus a context. The paper is + the citation for "one-layer-plate" as an *optic family*, not an ad-hoc library. + - **The van-Laarhoven-style theorem** (§4): `CartesianStore B A ≅ ∀κ. Applicative κ + => (B -> κ B) -> κ A`, and `Store B A ≅ ∀κ. Functor κ => (B -> κ B) -> κ A`. + This is the moral charter for eo's existential-carrier encoding + (`Optic[S, T, A, B, C[_]]` with leftover `X`): the paper proves the polymorphic + (Kleisli/existential) and coalgebraic (store) presentations of the same optic + coincide — the "two readings of the same fact" move the whole branch makes at + the X seam. Wadler's free-theorem machinery ([16] in the paper) is what carries + the proof. + - **Mutually recursive types** (§5, Multiplate proper) map onto the generics + module's macro derivation and the pattern-functor requirement: the branch's + `Project`/`Embed` basis is the same shape Multiplate demands per plate. + +## 1. References *of* the anchor paper that matter to this codebase + +- Mitchell, Runciman, *Uniform boilerplate and list processing* (Haskell Workshop + 2007) — **Uniplate**, the origin of Biplates and of the `Plated` name/class + family (`Plated.plate`, circe's `Plated[Json]`, the `PlatedBridgeSpec`). +- Bringert, Ranta, *A pattern for almost compositional functions* (ICFP 2006) — + **Compos**, the other half of the isomorphism theorem. +- Yakushev, Holdermans, Löh, Jeuring, *Generic programming with fixed points for + mutually recursive datatypes* (ICFP 2009) — multiparameter fixed points; the + general shape the `generics` module's derivation must respect for + mutually-recursive ADTs. +- Foster, Greenwald, Moore, Pierce, Schmitt, *Combinators for bi-directional tree + transformations* (POPL 2005) — the lens view-update problem; the put/get laws + `paraLens`'s get-put/put-get pinning instantiates. +- Johnson, Rosebrugh, Wood, *Algebras and Update Strategies* (JUCS 2010) — lenses + as algebras of a monad on a slice category; the coalgebra/algebra duality the + fold/unfold optic families sit on. +- McBride, Paterson, *Applicative programming with effects* (JFP 2008) — the + applicative half of the title theorem; `Traverse[F]` per-layer lawfulness. +- Bird, Meertens, *Nested Datatypes* (MPC 1998) — the nested-type obstacle the + Cartesian store comonad clears in Haskell 98; relevant to `Tree[+N]`-style + recursive parameterised ADTs in `eo-generics`. +- Uustalu, Vene, *Signals and Comonads* (JUCS 2005) — comonad machinery adjacent + to the decoration towers (zygo/histo). +- Lämmel, Kort, Visser, *Dealing with Large Bananas* (WGP 2000) — generalized + folds at scale; an early "zoo" unification attempt. +- Wadler, *Theorems for free!* (FPCA 1989) — the engine behind the §4 isomorphism + proof technique. + +## 2. Citations *of* the anchor paper relevant to the branch + +(From Semantic Scholar; filtered to what PR #24 actually builds on.) + +- Riley, *Categories of Optics* (2019) — optics as mixed optics; the store comonad + is the mixed choice for lenses, which is the "X is the leftover" story in + categorical dress. Cited in the plan's references (§4.10 achromatic variant). +- Pickering, Gibbons, Wu, *Profunctor Optics: Modular Data Accessors* (Programming + Journal 2017) — the profunctor reformulation of exactly O'Connor's theorem; the + "read-only-optics convention" the Affine build-seam carrier's sub-shape pinning cites. +- Kiss, Pickering, Wu, *Generic deriving of generic traversals* (Haskell 2018) — + deriving Traversal/Plate structure generically at compile time; the citation + for `eo-generics`' derivation ambitions beyond Lens/Prism. +- Gibbons, Johnson, *Relating algebraic and coalgebraic descriptions of lenses* + (BX 2012) — the get/put vs coalgebra duality spelled out. +- Ahman, Uustalu, *Coalgebraic update lenses* (ENTCS 2014) and *Taking Updates + Seriously* (MPCS 2017) — update-lens coalgebras; where put-get lawfulness for + decorated folds (paraLens, the memoized-refolds follow-up) would be grounded. +- Clarke, *Delta Lenses as Coalgebras for a Comonad* + ([arXiv:2108.00390](https://arxiv.org/abs/2108.00390), 2021) — modern successor; + cite if paraLens grows a delta-lens face. +- Capriotti, Danielsson, Vezzosi, *Higher Lenses* (LICS 2021) — the store comonad + iterated; the categorical limit of the "histo = iterated store" reading. +- López-González, Serrano, *Towards Optic-Based Algebraic Theories: The Case of + Lenses* (PSC 2018) and *The optics of language-integrated query* (SCP 2020) — + Scala-side optics theory; closest published kin to eo's carrier design. +- Morris, *Asymmetric Lenses in Scala* (2012) — the Scala lens lineage the + migration-from-monocle docs sit in. +- Ahman, Bauer, *Runners in Action* (ESOP 2020) — comonad-as-context machinery; + peripheral but in the same store-comonad generalization family. + +## 3. The recursion-schemes canon (the zoo's sources of truth) + +These are the papers the schemes themselves come from; the branch's plan docs +already cite the starred ones — collected here so the bibliography is complete +in one place. + +- Meertens, *Paramorphisms* (Formal Aspects of Computing 4(5), 1992) — para. +- Fokkinga, *Tupling and mutumorphisms* (The Squiggolist 1(4), 1990) — mutu. +- Vene, Uustalu, *Functional programming with apomorphisms (corecursion)* (Proc. + Estonian Acad. Sci. 47(3), 1998) — apo. +- ★ Uustalu, Vene, Pardo, *Recursion schemes from comonads* (ENTCS 2001) — the + comonadic-fold framework; zygo/histo/dyna as comonadic folds; the Decor + family's formal ancestor. +- Bartels, *Generalised coinduction* (MSCS 13, 2003) — gcata/gana; the g- + machinery the decorated unfolds (futu/apo) ride. +- Capretta, Uustalu, Vene, *Recursive coalgebras from comonads* (Information and + Computation 204, 2006) — **when a fold is productive/stack-safe**: the formal + counterpart of FusionSpec's "the scalar neck is the barrier" finding. Cite for + the fusion-law side conditions. +- Gibbons, *Metamorphisms: streaming representation-changers* (SCP 2007) — meta. +- ★ Hinze, Wu, Gibbons, *Unifying structured recursion schemes* (ICFP 2013) — + adjoint folds subsume comonadic folds; the matrix that Affine's + composition row (worn build-side by the decorations) targets. +- Hinze, Wu, *Histo- and dynamorphisms revisited* (WGP 2013) — histo/dyna/chrono + details, dynamic-programming framing; grounds the space-honesty note on `Attr`. +- Hinze, *Adjoint folds and unfolds — an extended study* (SCP 2013) — the + calculational toolkit behind the degeneration laws. +- ★ Yang, Wu, *Fantastic morphisms and where to find them* + ([arXiv:2202.13633](https://arxiv.org/abs/2202.13633), 2022) — the + practitioner's zoo catalogue; the naming reference for the `zoo/` package. +- Adámek, Milius, Vene, *Elgot algebras* (LMCS 2006) — the formal source for + elgot/coelgot; Kmett's 2008 *Elgot (Co)Algebras* post is the practical + rendering the branch cites. +- Kmett, `recursion-schemes` (Haskell library) — `distPara`/`distApo`/`micro`; + the reference shapes the zoo's bench rows compare against. +- Eades, Stump, Oliver, *Hylomorphisms in the wild* (MSFP 2020) — production + hylomorphism concerns (fusion, effects); adjacent to the M-driver story. + +## 4. Scala-ecosystem implementations to cite honestly + +- **droste** () — Gather/Scatter, + `hyloM`, the kernel design D4's bench rows and the Decor/Gather/Scatter + honest-encoding note compare against; also the stack-unsafe-basic-schemes + caveat the docs repeat. +- **Monocle** — the benchmarks' comparison baseline. +- **cats** (`Traverse`/`Monad.tailRecM`) — the lawful instances the machines + ride; stack-safety reduces to tailRecM lawfulness (tested per M, per D3/D5). + +## 5. How the bibliography should ship + +- `site/docs/schemes.md` "Further reading" section: the §3 canon + O'Connor §2 + (store-comonad lens) + Riley + Pickering–Gibbons–Wu. Nothing else; the docs' + claims must stay scoped to the shipped seams. +- Scaladoc pointers only where a claim is literally a theorem from a paper + (fusion law → Capretta–Uustalu–Vene; paraLens lawfulness → O'Connor §2.2 + + Riley). +- This file is the long-form reference; update it when the follow-ups (elgot + port, Affine matrix row, higher-order decoration) land so the citations + grow with the surface. diff --git a/laws/src/main/scala/dev/constructive/eo/laws/data/AffineLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/AffineLaws.scala index 8072f787..e81fcab1 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/AffineLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/AffineLaws.scala @@ -1,8 +1,9 @@ package dev.constructive.eo.laws.data import cats.{Applicative, Id} -import dev.constructive.eo.data.Affine -import dev.constructive.eo.forgetful.{ForgetfulFunctor, ForgetfulTraverse} +import dev.constructive.eo.accessor.{Graft, PartialAccessor} +import dev.constructive.eo.data.{Affine, Fst, Snd} +import dev.constructive.eo.forgetful.{ForgetfulFold, ForgetfulFunctor, ForgetfulTraverse} /** Carrier-level laws for `Affine[X, A]`. * @@ -17,6 +18,13 @@ import dev.constructive.eo.forgetful.{ForgetfulFunctor, ForgetfulTraverse} * the optic level (see [[dev.constructive.eo.laws.eo.OptionalComposeLaws]]); re-stating its * associativity equations as a standalone law class would duplicate that coverage without adding * signal. + * + * On the BUILD seam (see [[Affine.graft]]), the two arms are the decoration vocabulary — `Miss` = + * the slot is finished (an apo graft, a futu unroll), `Hit` = keep going. The build-channel laws + * pin the finished arm as *final*: invisible to the focus (`getOption` empty, `foldMap` empty) and + * inert under `map` — the carrier-shaped halves of "done is final"; the per-value + * `graft(done(t)) == t` equation is stated against concrete decoration citizens (which pin + * `Fst[X]`), not here. */ trait AffineLaws[X, A]: @@ -41,3 +49,33 @@ trait AffineLaws[X, A]: ): Boolean = FT.traverse[X, A, A, Id](fa, a => a: Id[A])(using Applicative[Id]) == fa + + // ----- Build-seam (Graft) laws — the finished arm is final ------------------------------ + + /** The finished arm carries no focus. */ + def doneHasNoFocus(fst: Fst[X])(using + G: Graft[Affine], + P: PartialAccessor[Affine], + ): Boolean = + P.getOption(G.done[X, A](fst)).isEmpty + + /** The keep-going arm carries exactly its focus. */ + def stepHasFocus(snd: Snd[X], a: A)(using + G: Graft[Affine], + P: PartialAccessor[Affine], + ): Boolean = + P.getOption(G.step[X, A](snd, a)).contains(a) + + /** The finished arm is inert under `map` — finished means finished. */ + def doneMapInert(fst: Fst[X], f: A => A)(using + G: Graft[Affine], + FF: ForgetfulFunctor[Affine], + ): Boolean = + FF.map(G.done[X, A](fst), f) == G.done[X, A](fst) + + /** The finished arm contributes nothing to a fold. */ + def doneFoldEmpty(fst: Fst[X])(using + G: Graft[Affine], + FD: ForgetfulFold[Affine], + ): Boolean = + FD.foldMap[X, A, Int](_ => 1, G.done[X, A](fst)) == 0 diff --git a/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/AffineTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/AffineTests.scala index 34d07d65..0cf63ca1 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/AffineTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/AffineTests.scala @@ -1,8 +1,9 @@ package dev.constructive.eo.laws.data.discipline import cats.Applicative -import dev.constructive.eo.data.Affine -import dev.constructive.eo.forgetful.{ForgetfulFunctor, ForgetfulTraverse} +import dev.constructive.eo.accessor.{Graft, PartialAccessor} +import dev.constructive.eo.data.{Affine, Fst, Snd} +import dev.constructive.eo.forgetful.{ForgetfulFold, ForgetfulFunctor, ForgetfulTraverse} import dev.constructive.eo.laws.data.AffineLaws import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen} @@ -17,9 +18,14 @@ abstract class AffineTests[X, A] extends Laws: def affine(using Arbitrary[Affine[X, A]], Arbitrary[A], + Arbitrary[Fst[X]], + Arbitrary[Snd[X]], Cogen[A], ForgetfulFunctor[Affine], + ForgetfulFold[Affine], ForgetfulTraverse[Affine, Applicative], + Graft[Affine], + PartialAccessor[Affine], ): RuleSet = new SimpleRuleSet( "Affine", @@ -29,4 +35,12 @@ abstract class AffineTests[X, A] extends Laws: forAll((fa: Affine[X, A], f: A => A, g: A => A) => laws.functorComposition(fa, f, g)), "traverse[Id] identity" -> forAll((fa: Affine[X, A]) => laws.traverseIdentity(fa)), + "done has no focus" -> + forAll((fst: Fst[X]) => laws.doneHasNoFocus(fst)), + "step has its focus" -> + forAll((snd: Snd[X], a: A) => laws.stepHasFocus(snd, a)), + "done is map-inert" -> + forAll((fst: Fst[X], f: A => A) => laws.doneMapInert(fst, f)), + "done folds empty" -> + forAll((fst: Fst[X]) => laws.doneFoldEmpty(fst)), ) diff --git a/schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/HyloLaws.scala b/schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/HyloLaws.scala deleted file mode 100644 index 9bedd758..00000000 --- a/schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/HyloLaws.scala +++ /dev/null @@ -1,44 +0,0 @@ -package dev.constructive.eo -package schemes -package laws - -import data.PSVec -import optics.Optic.* -import optics.Plated - -/** Law equations for the recursion schemes in [[Schemes]]. - * - * First citizen: the '''hylo fusion law''' — `Schemes.hylo`'s scaladoc claims the fused refold is - * "equal to `ana(…).cross(cata(alg))` on the same computation (the hylo law), but without - * materializing the structure". This trait turns that claim into a checkable contract: an instance - * supplies a coalgebra, the `S`-algebra the materializing side folds, and the seed-level fused - * algebra claimed to correspond to it; [[hyloFusion]] verifies the consequence on every generated - * seed. The seed expansion is *derived* from the coalgebra (`coalg(_)._1`), so the only coherence - * an instance asserts is the `alg` ↔ `fusedAlg` correspondence — an incoherent pair fails the law, - * which is the point. - * - * `equals` is used for the comparison, so `A` must have structural equality (every case class / - * enum and the primitives do). - * - * More scheme laws are expected to land here as the zoo grows — para / apo / histo / futu fusion, - * cata-compose (`cata(f) ∘ cata(g)` deforestation), ana-compose, and the `Plated`-coalgebra - * coherence (`childrenVec` deconstructs exactly what the coalgebra's builder constructs). - */ -trait HyloLaws[Seed, S, A](using val P: Plated[S]): - - /** The coalgebra under test — a seed yields its child seeds plus the node builder. */ - def coalg: Schemes.Coalg[Seed, S] - - /** The `S`-algebra folded by the materializing `ana.cross(cata)` side. */ - def alg: (S, PSVec[A]) => A - - /** The seed-level algebra claimed to correspond to [[alg]] over the nodes [[coalg]] builds. */ - def fusedAlg: (Seed, PSVec[A]) => A - - /** `hylo(coalg(_)._1, fusedAlg).get(seed) == ana(coalg).cross(cata(alg)).get(seed)` — the fused - * refold computes exactly what build-then-fold computes, with no intermediate `S`. - */ - def hyloFusion(seed: Seed): Boolean = - val expand: Seed => PSVec[Seed] = s => coalg(s)._1 - Schemes.hylo(expand, fusedAlg).get(seed) == - Schemes.ana(coalg).cross(Schemes.cata(alg)).get(seed) diff --git a/schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/discipline/HyloTests.scala b/schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/discipline/HyloTests.scala deleted file mode 100644 index 99d43f82..00000000 --- a/schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/discipline/HyloTests.scala +++ /dev/null @@ -1,23 +0,0 @@ -package dev.constructive.eo -package schemes -package laws -package discipline - -import org.scalacheck.Arbitrary -import org.scalacheck.Prop.forAll -import org.typelevel.discipline.Laws - -/** Discipline `RuleSet` for [[HyloLaws]]. Reusable by downstream projects to check the hylo fusion - * contract on their own coalgebra / algebra pairs. The seed generator should straddle the engines' - * on-stack depth limit (512) so both the recursive fast path and the heap machine are exercised - * under the equality. - */ -abstract class HyloTests[Seed, S, A] extends Laws: - def laws: HyloLaws[Seed, S, A] - - def hylo(using Arbitrary[Seed]): RuleSet = - new SimpleRuleSet( - "Hylo", - "hylo(expand, fused) == ana(coalg) cross cata(alg)" -> - forAll((seed: Seed) => laws.hyloFusion(seed)), - ) diff --git a/schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/package.scala b/schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/package.scala deleted file mode 100644 index 130f569e..00000000 --- a/schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/package.scala +++ /dev/null @@ -1,17 +0,0 @@ -package dev.constructive.eo -package schemes - -/** Law definitions for the recursion schemes in [[Schemes]] — separate from `cats-eo-laws` because - * these laws quantify over `schemes` types (`Schemes.Coalg`, `PSVec`, the fold machinery), which - * the core law module deliberately does not depend on. Same discipline pattern as - * [[dev.constructive.eo.laws]]: one `*Laws` trait of law equations here, its ScalaCheck/specs2 - * `RuleSet` bundle under [[laws.discipline]], wired by overriding `laws` and `checkAll`-ed from - * your suite with your own generators. - * - * Coverage so far is the '''hylo fusion law ONLY''' ([[HyloLaws]] / `discipline.HyloTests`): - * `hylo(expand, fused)` computes exactly what `ana(coalg).cross(cata(alg))` computes, with no - * intermediate structure. More scheme laws are expected to land here as the zoo grows — para / apo - * / histo / futu fusion, cata-compose and ana-compose deforestation, and `Plated`-coalgebra - * coherence; see the [[HyloLaws]] scaladoc for the roadmap. - */ -package object laws diff --git a/schemes-laws/src/test/scala/dev/constructive/eo/schemes/laws/HyloLawsSpec.scala b/schemes-laws/src/test/scala/dev/constructive/eo/schemes/laws/HyloLawsSpec.scala deleted file mode 100644 index 1ef49f9b..00000000 --- a/schemes-laws/src/test/scala/dev/constructive/eo/schemes/laws/HyloLawsSpec.scala +++ /dev/null @@ -1,56 +0,0 @@ -package dev.constructive.eo -package schemes -package laws - -import org.scalacheck.{Arbitrary, Gen} -import org.specs2.mutable.Specification -import org.typelevel.discipline.specs2.mutable.Discipline - -import data.PSVec -import optics.Plated -import schemes.laws.discipline.HyloTests - -// Top-level fixture (mirrors PlatedSpec's hoisting convention; no macro involved here, -// but a top-level ADT keeps the fixture shareable with future scheme-law specs). -enum HyloExpr: - case Lit(v: Double) - case Add(l: HyloExpr, r: HyloExpr) - -class HyloLawsSpec extends Specification with Discipline: - - private given Plated[HyloExpr] = Plated.fromChildren( - { - case HyloExpr.Add(l, r) => List(l, r) - case HyloExpr.Lit(_) => Nil - }, - { - case (HyloExpr.Add(_, _), l :: r :: Nil) => HyloExpr.Add(l, r) - case (leaf, _) => leaf - }, - ) - - // Seed n builds a right-nested Add of (n+1) Lit(1.0) leaves — tree DEPTH is n, so the - // 480..600 band straddles the engines' OnStackLimit (512) and drives unfoldFold, - // unfoldCoalg, AND foldInPlace through their heap-machine fallback under the equality. - private given Arbitrary[Int] = Arbitrary( - Gen.frequency( - 3 -> Gen.choose(0, 48), - 2 -> Gen.choose(480, 600), - ) - ) - - checkAll( - "Schemes.hylo (right-nested Add eval, depth straddling OnStackLimit)", - new HyloTests[Int, HyloExpr, Double]: - val laws = new HyloLaws[Int, HyloExpr, Double]: - val coalg: Schemes.Coalg[Int, HyloExpr] = n => - if n <= 0 then (PSVec.empty[Int], (_: PSVec[HyloExpr]) => HyloExpr.Lit(1.0)) - else (PSVec.of(0, n - 1), (ks: PSVec[HyloExpr]) => HyloExpr.Add(ks(0), ks(1))) - val alg: (HyloExpr, PSVec[Double]) => Double = (node, kids) => - node match - case HyloExpr.Lit(v) => v - case HyloExpr.Add(_, _) => kids(0) + kids(1) - val fusedAlg: (Int, PSVec[Double]) => Double = - (n, rs) => if n <= 0 then 1.0 else rs(0) + rs(1) - .hylo, - ) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala new file mode 100644 index 00000000..bad0941d --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala @@ -0,0 +1,10 @@ +package dev.constructive.eo +package schemes + +/** The pattern-functor correspondence `Project` / `Embed` / `Basis` now lives in `core` + * ([[dev.constructive.eo.optics.Basis]]) so that core's [[dev.constructive.eo.optics.Plated]] can + * derive from it ([[dev.constructive.eo.optics.Plated.fromBasis]]). Re-exported here at the + * `schemes` package level so every scheme citizen keeps referring to `Project` / `Embed` / `Basis` + * unqualified, exactly as before the move. + */ +export optics.{Basis, Embed, Project} diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala new file mode 100644 index 00000000..4d6eebdf --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -0,0 +1,400 @@ +package dev.constructive.eo +package schemes + +import scala.annotation.tailrec + +import cats.{Monad, Traverse} + +/** Internal stack-safe fold engines for the typed recursion-scheme path. + * + * ==Shape== + * + * Every engine is the same two-phase walk — '''descend''' (peel a layer, push a [[Frame]] per + * interior node onto the frame stack) and '''bubble''' (store a finished child's result into the + * top frame's slot, then resume at the next sibling or combine the completed frame) — expressed as + * ONE `@tailrec` loop whose state is the [[Ascend]] sentinel encoding shared with + * [[foldLayeredM]]'s `tailRecM` loop (two mutually-recursive phase functions would grow the JVM + * stack on their cross-calls; a single self-tail-recursive loop compiles to a jump). The pure + * machines share their walk ([[heapWalk]]); the `M` machine is the same walk threaded through + * `tailRecM`. + * + * Slot buffers are union-typed ([[Slot]] = `N | R`): a cell starts life as the child node and is + * overwritten in place by that child's result, so stores are cast-free and each phase narrows its + * reads at one documented point. Below [[OnStackLimit]] the engines use plain tree recursion + * instead (the natural functional expression of a fold, and the allocation-free hot path); only + * deep subtrees pay for frames. + * + * ==Thread-safety model== + * + * Every machine allocates its mutable state (the frame stack, the per-node slot buffers) '''per + * invocation''' — and, for the `M` path, per '''force''', inside `M.flatMap(M.unit)` so that + * re-forcing the same `M[R]` value allocates fresh state on each evaluation. No mutable state is + * shared across invocations or forces. + * + * The only shared values are immutable sentinels: + * + * - [[NoChildren]]: a zero-length slot buffer, shared by all leaf layers (see its own scaladoc + * for the immutability argument). + * - [[Ascend]] / [[NoResult]]: stable identity objects marking the loop's bubble states. Never + * written, no mutable state. + * + * Concurrent invocations of recursion schemes in a single JVM process are therefore safe — each + * call owns its own heap region and neither reads nor writes the shared sentinels' contents. + * '''Concurrent forcing of a single `M[R]` value''' is a different question and remains + * unsupported (two concurrent forces of the exact same suspended `M[R]` could interleave their + * tailRecM steps); each `run(s)` call returns an independent `M[R]`, and those are safe to force + * concurrently. + */ +private[schemes] object Machines: + + /** Depth at which the on-stack recursion hands a subtree to the heap machine — mirrors + * `Plated.transformRecursionLimit`. Balanced trees (depth ~log n) never reach it. + */ + final val OnStackLimit = 512 + + /** One child/result buffer cell: starts life as the child node `N`, overwritten in place by that + * child's folded result `R`. The walk's index discipline decides which half is live — cells + * below a frame's `next` hold results, cells at and above it still hold children — so stores are + * cast-free (`N <: Slot` and `R <: Slot`) and each phase narrows its reads at one point. + */ + private[schemes] type Slot[N, R] = N | R + + /** "Bubble" loop-state marker: consume the pending result against the top frame. */ + private[schemes] object Ascend + + /** Loop op state: the node to descend into, or [[Ascend]]. */ + private[schemes] type Op[N] = N | Ascend.type + + /** Placeholder for the pending-result state while descending (no result is in flight). Never read + * — `pending` is consumed only on the [[Ascend]] arm, which is reached only after a real result + * was threaded in. + */ + private[schemes] object NoResult + + /** The pending-result loop state: a result bubbling up, or [[NoResult]] while descending. */ + private[schemes] type Pending[R] = R | NoResult.type + + // === The engine's only unchecked narrowings ================================================ + // Abstract type params `N` / `R` have NO runtime type test — `case n: N` is rejected under + // -Werror ("the type test for N cannot be checked at runtime"), and Scala 3 does not subtract + // a matched singleton from a union binder (`N | Ascend.type` minus `Ascend.type` is not `N`). + // So narrowing a Slot / Op / Pending to its live half is necessarily an `asInstanceOf`. Every + // such cast lives HERE, `inline` (zero overhead — same bytecode as the bare cast) and named by + // the walk invariant it relies on, instead of scattered through the loop bodies. These five are + // the engine's complete unchecked-narrowing surface; the two remaining casts (the builder + // widening + the array-erasure reinterpret in `childrenSlots`) are always-safe boundary casts, + // not narrowings. + + /** A slot still holding its child node — the walk reads this only at indices `>= frame.next` + * (slots below `next` already hold results), or for a freshly-built layer's slot 0. + */ + private inline def childAt[N, R](slot: Slot[N, R]): N = slot.asInstanceOf[N] + + /** A slot holding a fold result — the walk reads this only at indices `< frame.next`. */ + private inline def resultAt[N, R](slot: Slot[N, R]): R = slot.asInstanceOf[R] + + /** The descend target carried by the loop op — read only on the non-[[Ascend]] arm. */ + private inline def nodeOf[N](op: Op[N]): N = op.asInstanceOf[N] + + /** The pending result — read only on the [[Ascend]] arm, reached only after a real result was + * threaded in (so `pending` is never [[NoResult]] here). + */ + private inline def forced[R](pending: Pending[R]): R = pending.asInstanceOf[R] + + /** Phantom-recast an empty leaf layer `F[A]` to `F[B]` — valid because a pattern-functor leaf has + * no recursive slots, so no `A` is ever read as a `B`. Avoids the `F.map` reallocation a leaf + * would otherwise pay (the leaf is the most common node; this is allocation-pinned). + */ + private inline def leafRecast[F[_], A, B](fn: F[A]): F[B] = fn.asInstanceOf[F[B]] + + /** The shared "this layer has no children" sentinel. + * + * '''What it is:''' a single zero-length slot buffer, allocated once and returned by + * [[childrenSlots]] for every leaf layer (`LeafF`-like constructors with no recursive slots). + * + * '''Who uses it:''' every engine, via [[childrenSlots]] — leaf layers are the most common case + * in typed pattern functors, so the shared sentinel avoids a per-leaf empty-array allocation. + * + * '''Why it is thread-safe:''' the array has length 0 and no element is ever written into it — + * every store in the engines targets `slots(i)` for `i < slots.length`. An immutable zero-length + * array is safe to share across any number of concurrent walks. + */ + private val NoChildren: Array[AnyRef] = new Array[AnyRef](0) + + /** Collect the children of typed layer `fn` into a flat slot buffer, single-pass via + * `ObjArrBuilder`; [[NoChildren]] for leaf layers. (`Array[Slot[N, R]]` erases to + * `Array[AnyRef]` — the recast here is the buffer's single allocation-site cast; every + * subsequent store is union-typed.) + */ + private[schemes] def childrenSlots[F[_], N, R](fn: F[N])(using + F: Traverse[F] + ): Array[Slot[N, R]] = + val n = F.size(fn).toInt + val raw = + if n == 0 then NoChildren + else + val b = new data.ObjArrBuilder(n) + val _ = F.foldLeft(fn, ()) { (_, child) => + b.unsafeAppend(child.asInstanceOf[AnyRef]) + } + b.freezeArr + raw.asInstanceOf[Array[Slot[N, R]]] + + /** One suspended interior node: its layer, the child/result slot buffer, and the index of the + * next slot awaiting a result (slots below `next` hold results, slots at and above it still hold + * children). + */ + final private class Frame[F[_], N, R]( + val node: N, + val layer: F[N], + val slots: Array[Slot[N, R]], + var next: Int, + ) + + /** Rebuild a typed `F[R]` from the original layer `fn: F[N]` and its children's results, stored + * positionally in `out` in `Foldable` order — which `Functor.map` matches for a lawful + * `Traverse`. Lets the schemes hand the algebra a typed `F[R]` (named constructors) rather than + * a positional vector. Leaf layers are phantom-recast (valid because pattern-functor leaves have + * no recursive slots by definition); non-leaf reads narrow the slot union (every cell holds an + * `R` by the time a layer is rebuilt). + */ + /** [[rebuildLayer]]'s paramorphic sibling: pair each original child `N` with its folded result + * from `out` (positional, `Foldable` order — which `Functor.map` matches for a lawful + * `Traverse`). The subterms come from the layer the machine already holds, so there is no + * per-node re-`project` and no per-node `List` — [[zoo.Para]]'s route, and what keeps it at half + * droste's B/op. `private`: it takes the raw slot buffer, which must not leave this file. + */ + private def rebuildLayerPaired[F[_], N, R](fn: F[N], out: Array[Slot[N, R]])(using + F: Traverse[F] + ): F[(N, R)] = + if out.length == 0 then leafRecast(fn) + else + var i = -1 + F.map(fn) { n => + i += 1 + (n, resultAt(out(i))) + } + + private[schemes] def rebuildLayer[F[_], N, R](fn: F[N], out: Array[Slot[N, R]])(using + F: Traverse[F] + ): F[R] = + if out.length == 0 then leafRecast(fn) + else + var i = -1 + F.map(fn) { _ => + i += 1 + resultAt(out(i)) + } + + /** The descend/bubble heap walk shared by [[foldLayered]] and [[foldLayeredOr]] (previously two + * near-identical loops). `expandOr`'s `Left` arm is the graft/short-circuit channel — + * [[foldLayered]] instantiates it with a constant `Right`. This walk runs only past + * [[OnStackLimit]] — the cold path (the hot on-stack recursion stays specialized in each + * engine), so the step parameters are ordinary functions; nothing here is hot enough for + * inlining to matter. + * + * The loop body delegates to two `transparent inline` phase helpers; their `loop` calls are in + * tail position after inlining, so `@tailrec` still verifies. + */ + private def heapWalk[F[_], N, R]( + root: N, + expandOr: N => Either[R, F[N]], + combine: (N, F[R]) => R, + )(using F: Traverse[F]): R = + heapWalkSlot(root, expandOr, (n, layer, slots) => combine(n, rebuildLayer(layer, slots))) + + /** [[heapWalk]] with the slot buffer threaded to the combine (the [[foldLayeredSlot]] cold path). + * Same walk; the 3-arg combine receives the already-filled slot buffer. + */ + private def heapWalkSlot[F[_], N, R]( + root: N, + expandOr: N => Either[R, F[N]], + combine: (N, F[N], Array[Slot[N, R]]) => R, + )(using F: Traverse[F]): R = + @tailrec def loop(op: Op[N], pending: Pending[R], stack: List[Frame[F, N, R]]): R = + + transparent inline def descend(n: N): R = expandOr(n) match + case Left(finished) => loop(Ascend, finished, stack) // graft: finished, by reference + case Right(layer) => + val slots = childrenSlots[F, N, R](layer) + if slots.length == 0 then loop(Ascend, combine(n, layer, slots), stack) + else loop(childAt(slots(0)), NoResult, new Frame(n, layer, slots, 0) :: stack) + + transparent inline def bubble: R = stack match + case Nil => forced(pending) // the walk's final result + case fr :: rest => + fr.slots(fr.next) = forced(pending) // overwrite the just-folded child's slot + fr.next += 1 + if fr.next < fr.slots.length then loop(childAt(fr.slots(fr.next)), NoResult, stack) + else loop(Ascend, combine(fr.node, fr.layer, fr.slots), rest) + + op match + case Ascend => bubble + case n => descend(nodeOf(n)) // the op union's other inhabitant is the node + + loop(root, NoResult, Nil) + + /** Shared typed engine for the `F`-path schemes. `expand` peels a node into one typed layer of + * child nodes; the engine folds each child to an `R` (post-order), rebuilds the layer's results + * into a typed `F[R]` (named constructors — the raw `Slot` buffer never leaves the engine), then + * calls `combine` with the node and that `F[R]`. `< [[OnStackLimit]]` deep: plain tree + * recursion; past it, the shared [[heapWalk]]. Stack-safe for any *terminating* `expand` (a + * non-terminating one exhausts the heap — `OutOfMemoryError` — rather than the stack). + */ + private[schemes] def foldLayered[F[_], N, R]( + expand: N => F[N], + combine: (N, F[R]) => R, + )(using F: Traverse[F]): N => R = + foldLayeredSlot(expand, (n, layer, slots) => combine(n, rebuildLayer(layer, slots))) + + /** [[foldLayered]]'s subterm-retaining sibling — the combine receives the node's own layer with + * each child **paired with its folded result** (`F[(N, R)]`): [[zoo.Para]]'s shape, whose + * algebra reads the original subterm alongside the recursion result. Same walk, same + * stack-safety, and allocation-identical to [[foldLayered]] bar the pairs themselves — the + * pairing reads the layer the machine already expanded (no per-node re-`project`, no per-node + * `List`). + */ + private[schemes] def foldLayeredPaired[F[_], N, R]( + expand: N => F[N], + combine: (N, F[(N, R)]) => R, + )(using F: Traverse[F]): N => R = + foldLayeredSlot(expand, (n, layer, slots) => combine(n, rebuildLayerPaired(layer, slots))) + + /** The slot-level core both the [[foldLayered]] and [[foldLayeredPaired]] drivers run on: the + * combine sees the expanded layer plus the already-filled slot buffer, so it can rebuild either + * the results layer ([[rebuildLayer]]) or the paired layer ([[rebuildLayerPaired]]) without the + * engine pre-building the one it does not want. `private` — the raw `Slot` buffer must not leave + * this file (the drivers above are the typed surface). + */ + private def foldLayeredSlot[F[_], N, R]( + expand: N => F[N], + combine: (N, F[N], Array[Slot[N, R]]) => R, + )(using F: Traverse[F]): N => R = + + def rec(n: N, depth: Int): R = + if depth >= OnStackLimit then heapWalkSlot(n, m => Right(expand(m)), combine) + else + val layer = expand(n) + val slots = childrenSlots[F, N, R](layer) + var i = 0 + while i < slots.length do + slots(i) = rec(childAt(slots(i)), depth + 1) + i += 1 + combine(n, layer, slots) + + n => rec(n, 0) + + /** The unfold driver — [[foldLayered]] with the combine fixed to `Embed`, the shape every + * non-grafting unfold shares (`ana` / `futu` / `cozygo` / `comutu`, and the unfold half of the + * metamorphisms): peel each seed with `expand`, glue each rebuilt layer back with `embed`. + */ + private[schemes] def buildLayered[F[_], N, S](expand: N => F[N])(using + F: Traverse[F], + E: Embed[F, S], + ): N => S = + foldLayered[F, N, S](expand, (_, fr) => E.embed(fr)) + + /** [[foldLayered]]'s graft-aware sibling — the apomorphism engine. `expandOr` answers each node + * event with `Left(r)` (an **already-finished result**: placed into its slot directly — O(1), no + * recursion, no projection) or `Right(layer)` (keep going). Same on-stack / [[heapWalk]] hybrid + * and stack-safety as [[foldLayered]]. + */ + private[schemes] def foldLayeredOr[F[_], N, R]( + expandOr: N => Either[R, F[N]], + combine: F[R] => R, + )(using F: Traverse[F]): N => R = + + def rec(n: N, depth: Int): R = + if depth >= OnStackLimit then heapWalk(n, expandOr, (_, fr) => combine(fr)) + else + expandOr(n) match + case Left(r) => r // graft: finished, by reference + case Right(layer) => + val slots = childrenSlots[F, N, R](layer) + var i = 0 + while i < slots.length do + slots(i) = rec(childAt(slots(i)), depth + 1) + i += 1 + combine(rebuildLayer(layer, slots)) + + n => rec(n, 0) + + // =========================================================================================== + // The M-generic path — the heapWalk LIFTED into a Monad[M] (no M = Id special-case: that is + // what makes the fast-path agreement laws a real cross-architecture pin). One M-action per + // node event, threaded through Monad[M].tailRecM (each step paying tailRecM's per-event + // Either — the structural B/op floor vs the pure machine). NOT droste's hyloM + // (flatMap-recursive: O(depth) call stack on a strict M). Stack-safety reduces to the + // lawfulness of M's tailRecM — per-M and tested (Id/Eval to 10^6). + // + // Supported Ms are SINGLE-PASS and LINEAR: the walk's state is mutable, so a branching / + // replaying M (List, retrying or streaming effects) shares it across branches and corrupts + // the fold — the documented contract, exercised by the boundary test in SchemesMSpec. + // M must also be SEQUENTIALLY evaluated — each map/flatMap callback completes before the + // next tailRecM step (true of Id/Eval/State/IO); async/concurrent step evaluation is + // unsupported even for lawful Monads. + // + // The expand is Or-SHAPED (N => M[Either[R, F[N]]]) per the elgot-seam gate + // (docs/brainstorms/2026-06-12-elgot-seam-sketch.md): v1 drivers always pass Right; the + // elgot/apoM follow-up supplies Left answers with no re-architecture. + // =========================================================================================== + + /** The lifted machine. `M.tailRecM` is the loop; each iteration handles one node event — either a + * '''descend''' into the node carried by the loop state, or (on the [[Ascend]] sentinel) a + * '''bubble''' step against the top frame. The mutable walk state is allocated per-force (inside + * the `M`), so re-forcing the same `M[R]` value is safe; concurrent forcing of a single `M[R]` + * value is not (see the object scaladoc). + * + * Loop-state encoding (allocation-lean — CI 2026-06-12: per-event `Either` nesting dominated the + * M path's B/op): the state is an [[Op]] — [[Ascend]] means "bubble", anything else is the node + * to descend into; the ascend transition is a hoisted constant. The frame stack is an + * `ArrayDeque`, not a `List`: this machine has no on-stack phase, so it frames EVERY interior + * node — deque slot reuse is CI-visible (List conses cost ~+98k B/op on eoHyloM). + */ + private[schemes] def foldLayeredM[M[_], F[_], N, R]( + expandOr: N => M[Either[R, F[N]]], + combine: (N, F[R]) => M[R], + )(using M: Monad[M], F: Traverse[F]): N => M[R] = + n0 => + M.flatMap(M.unit) { _ => + val stack = new java.util.ArrayDeque[Frame[F, N, R]]() + var pending: Pending[R] = NoResult + val ascend: Either[Op[N], R] = Left(Ascend) + + inline def bubbled(r: R): Either[Op[N], R] = + pending = r + ascend + + def onDescend(n: N): M[Either[Op[N], R]] = + M.flatMap(expandOr(n)) { + case Left(finished) => M.pure(bubbled(finished)) // graft / short-circuit arm + case Right(layer) => + val slots = childrenSlots[F, N, R](layer) + if slots.length == 0 then + // leaf: combine inline — no frame, no extra loop event + M.map(combine(n, rebuildLayer(layer, slots)))(bubbled) + else + stack.push(new Frame(n, layer, slots, 0)) + M.pure(Left(childAt(slots(0)))) + } + + def onAscend(): M[Either[Op[N], R]] = + val fr = stack.peek() + if fr == null then M.pure(Right(forced(pending))) + else + fr.slots(fr.next) = forced(pending) // store the just-folded child's result + fr.next += 1 + if fr.next < fr.slots.length then M.pure(Left(childAt(fr.slots(fr.next)))) + else + // last child stored: combine now — no intermediate pure event + M.map(combine(fr.node, rebuildLayer(fr.layer, fr.slots))) { r => + val _ = stack.pop() + bubbled(r) + } + + M.tailRecM[Op[N], R](n0) { op => + op match + case Ascend => onAscend() + case n => onDescend(nodeOf(n)) + } + } diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala index 17dc2986..25b57aa9 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -1,238 +1,427 @@ package dev.constructive.eo package schemes -import scala.annotation.tailrec +import cats.{~>, Monad, Traverse} -import java.util.ArrayDeque +import data.{Affine, MultiFocus} +import optics.{GetReplaceLens, Lens, Optic} +import optics.Optic.get +import zoo.* -import data.PSVec -import optics.{Getter, Plated, Review, Unfold} - -/** Recursion schemes as composable optics, built on the core optic surface. +/** Typed recursion schemes as composable optics, over a user-supplied **pattern functor** `F[_]` (+ + * `Traverse[F]`) and the [[Basis]] (`Project`/`Embed`) correspondence to the recursive type `S`. + * + * ==The thesis== + * + * A recursion scheme is an [[dev.constructive.eo.optics.Optic]] over the + * [[dev.constructive.eo.data.Direct]] carrier whose existential `X` is the *index* of the + * recursion — what the scheme retains — and **the (co)free (co)monads are the universal indices**: + * + * | scheme | `X` | index | + * |:-----------|:--------------------------------|:----------------------------------------------| + * | [[cata]] | `Nothing` | the forgetful (trivial) fold | + * | [[zygo]] | `F[(B, A)]` | store comonad over an auxiliary carrier `B` | + * | [[para]] | `F[(S, A)]` | the **store-comonad** complement (subterms) | + * | [[histo]] | [[zoo.Attr]] = `νX. A × F[X]` | the **cofree comonad** (course-of-value fold) | + * | [[ana]] | `S` | the materialising unfold | + * | [[cozygo]] | `Either[B, A]` | *g-apo* residual over an auxiliary coalgebra | + * | [[apo]] | `Either[S, A]` | the **Prism** residual (graft, build-side) | + * | [[futu]] | [[zoo.Coattr]] = `μX. A + F[X]` | the **free monad** (multi-layer unfold) | + * + * `para`/`histo` refine `cata`'s index up the comonad tower; `apo`/`futu` refine `ana`'s up the + * monad tower. (`para`'s existential is the writable-Lens complement — get-put holds + * definitionally, put-get only under algebra-coherence, so the lawful writable put is a scoped + * follow-up.) + * + * The towers also have an *auxiliary* rung between the trivial and store/prism indices: [[zygo]] + * (`X = F[(B, A)]`, the store comonad over an arbitrary carrier `B` — `para` is `zygo` at `B = S`) + * and its mutual-recursion generalisation [[mutu]] (`X = F[(A, B)]`), with build-side duals + * [[cozygo]] (`X = Either[B, A]`, *g-apo*) and [[comutu]] (`X = Either[A, B]`). * - * - [[cata]] is a `Getter[S, A]` driven by `Plated[S]` — the structural fold, generalising - * `Plated.transform` from `S => S` to `S => A`. - * - [[ana]] is a `Review[S, Seed]` — the unfold (build `S` from a seed), taking a [[Coalg]]. - * - [[hylo]] is a **fused** `Getter[Seed, A]` — refold with **no intermediate `S`** built. + * Orthogonal to both towers is the **natural-transformation axis** — [[prepro]] / [[postpro]] keep + * the trivial index (`cata`/`ana`-shaped) and instead pre/post-compose the layer optic + * ([[fLayer]]) with an accumulating `η : F ~> F`, so a node at depth `k` is transformed `k` times + * (`O(n · depth)`; `η = id` recovers `cata`/`ana`). * - * Because they produce core optic types, they compose with the rest of the optic algebra: - * `someLens.andThen(cata(alg))`, and the materializing `ana(…).cross(cata(…))` (via the core - * `Optic.cross` combinator) — the latter equal to `hylo` on the same computation (the hylo law). + * ==hylo is the fusion, not a primitive — and meta is the honest non-fusion== * - * Two usage modes. Run the optic directly (`cata(alg).get(tree)`, `ana(coalg).reverseGet(seed)`, - * `hylo(expand, alg).get(seed)`) — or hand it to capability-consuming code: the concrete optic - * types implement the capability traits, so a [[cata]] / [[hylo]] result satisfies `CanGet[S, A]` - * (and `CanFold[S, A]`) and an [[ana]] result satisfies `CanReverseGet[S, Seed]`, meaning a - * consuming signature like - * {{{ - * def report[S](s: S)(using g: CanGet[S, Int]): String - * }}} - * accepts a catamorphism without ever naming `Getter`. + * [[ana]] is a build (`Review`-shaped) and [[cata]] a node-blind fold (`Getter`-shaped); the + * build⇄read seam `ana.cross(cata)` (definitionally `ana.reverse.andThen(cata)`) **fuses** — the + * citizens keep their `coalg`/`alg` alive — into [[zoo.Hylo]], building *no intermediate `S`*. The + * `FusionSpec` pins the hylo law and witnesses the deforestation (the fused refold never calls + * `project`/`embed`). * - * The *fold* schemes ([[cata]] / [[hylo]]) take an algebra `(N, PSVec[R]) => R` — a node plus its - * already-folded children (paramorphism-flavored). The *build* scheme ([[ana]]) takes a [[Coalg]], - * the canonical anamorphism shape: a seed yields its child seeds together with how to assemble the - * node. Both run on one stack-safe engine: a `< 512`-deep on-stack fast path (no heap frames) that - * falls back, per deep subtree, to a heap `ArrayDeque` machine — the same hybrid as - * `Plated.transform`. Shallow trees pay no frame allocation; arbitrarily deep ones stay - * stack-safe. + * The **fold→unfold** seam `cata.meta(ana)` is the direction-dual ([[meta]], the metamorphism), + * and it **cannot fuse**: fold and unfold range over *different* functors, so the neck value is + * materialised (the [[zoo.Meta]] existential is `X = A`, not `Nothing`). The 2×2 the two seams + * complete — refold vs metamorphism × trivial vs universal index — is [[zoo.Hylo]] / [[zoo.Meta]] + * / [[zoo.Chrono]] / [[zoo.MetaChrono]]; the quadrant's diagonals are [[zoo.Dyna]] + * (`ana.cross(histo)`) and [[zoo.Codyna]] (`futu.cross(cata)`). [[zoo.Elgot]] / [[zoo.Coelgot]] + * are the short-circuit / seed-reading refold variants. + * + * ==Shape== + * + * Every scheme is a `final class` in [[zoo]] carrying its run/build function (the construction and + * machine-wiring live in each class's companion); this object is the user-facing **factory + * listing** — one-line delegations — plus [[fLayer]]. All schemes run on one stack-safe engine + * ([[Machines.foldLayered]]): a `< 512`-deep on-stack fast path falling back per deep subtree to a + * heap `ArrayDeque` machine — stack-safe to 10⁶, tested. */ object Schemes: - /** Closure-carrying coalgebra (the anamorphism input): a seed yields its child seeds plus a - * combiner from the built/folded child results. A leaf is `(PSVec.empty, _ => value)`. The - * combiner is handed a `PSVec[R]` of the same length and order as the child-seed vector — index - * it consistently with that arity (reading `kids(1)` of a 1-element vector throws - * `IndexOutOfBounds`; ignoring `kids(2)` of a 3-element one silently drops that subtree). A - * builder that captures nothing (e.g. `ks => Node(ks(0), ks(1))`) is a singleton in Scala 3, so - * the per-node cost of this bundled shape is just the tuple. - */ - type Coalg[N, R] = N => (PSVec[N], PSVec[R] => R) - - /** Depth at which the on-stack recursion hands a subtree to the heap machine — mirrors - * `Plated.transformRecursionLimit`. Balanced trees (depth ~log n) never reach it. - */ - final private val OnStackLimit = 512 - - /** Engine for the *build* scheme ([[ana]]) and — via a per-node `(kids, combine)` bundling of - * `expand` + `alg` — for the fused [[hylo]]. One [[Coalg]] call per node yields its children and - * its combiner closure (stored in the frame on the heap path). On-stack fast path below - * [[OnStackLimit]], heap `ArrayDeque` machine past it; stack-safe for any terminating coalgebra. - */ - private def unfoldCoalg[N, R](coalg: Coalg[N, R]): N => R = - n0 => unfoldCoalgRec(coalg, n0, 0) - - /** On-stack fast path for [[unfoldCoalg]]: one [[Coalg]] call per node yields its children and - * combiner; recurses directly up to [[OnStackLimit]], then defers to [[unfoldCoalgHeap]]. The - * inner `@tailrec loop` fills the child-result slots left to right. - */ - private def unfoldCoalgRec[N, R](coalg: Coalg[N, R], n: N, depth: Int): R = - if depth >= OnStackLimit then unfoldCoalgHeap(coalg, n) - else - val (kids, combine) = coalg(n) - val k = kids.length - if k == 0 then combine(PSVec.empty[R]) - else - val out = new Array[Any](k) - @tailrec def loop(i: Int): Unit = - if i < k then - out(i) = unfoldCoalgRec(coalg, kids(i), depth + 1) - loop(i + 1) - loop(0) - combine(PSVec.unsafeWrap[R](out)) - - /** Heap trampoline for [[unfoldCoalg]]: an explicit `ArrayDeque` post-order walk with each node's - * combiner closure stored in its frame. `enter` and the `@tailrec loop` driver share the one - * `stack` + `ret` cell; `loop`'s self-call stays in tail position for stack-safety. - */ - private def unfoldCoalgHeap[N, R](coalg: Coalg[N, R], root: N): R = - final class Frame( - val combine: PSVec[R] => R, - val kids: PSVec[N], - val out: Array[Any], - var i: Int, - ) - val stack = new ArrayDeque[Frame]() - var ret: Any = null - def enter(n: N): Unit = - val (kids, combine) = coalg(n) - if kids.isEmpty then ret = combine(PSVec.empty[R]) - else stack.push(new Frame(combine, kids, new Array[Any](kids.length), 0)) - enter(root) - @tailrec def loop(): R = - if stack.isEmpty then ret.asInstanceOf[R] - else - val fr = stack.peek() - if fr.i > 0 then fr.out(fr.i - 1) = ret - if fr.i < fr.kids.length then - val child = fr.kids(fr.i) - fr.i += 1 - enter(child) - else - ret = fr.combine(PSVec.unsafeWrap[R](fr.out)) - val _ = stack.pop() - loop() - loop() - - /** In-place fold engine for [[cata]]. `childrenOf` returns a **fresh, owned** `Array[Any]` of the - * node's children (via `Plated.childrenArray`); the engine folds each child and **overwrites its - * slot with the result**, reusing that one array as the result accumulator instead of allocating - * a separate out-array per node — then wraps it once for `alg`. Same on-stack/heap hybrid and - * stack-safety as [[unfoldCoalg]]. Safe because `childrenArray`'s contract guarantees the array - * is freshly allocated and not aliased. - */ - private def foldInPlace[S, A](childrenOf: S => Array[Any], alg: (S, PSVec[A]) => A): S => A = - s0 => foldInPlaceRec(childrenOf, alg, s0, 0) - - /** On-stack fast path for [[foldInPlace]]: post-order recursion up to [[OnStackLimit]] that folds - * each child **into its own slot** of the freshly-owned children array (reusing it as the result - * accumulator), then defers deep subtrees to [[foldInPlaceHeap]]. - */ - private def foldInPlaceRec[S, A]( - childrenOf: S => Array[Any], - alg: (S, PSVec[A]) => A, - s: S, - depth: Int, - ): A = - if depth >= OnStackLimit then foldInPlaceHeap(childrenOf, alg, s) - else - val arr = childrenOf(s) - val k = arr.length - if k == 0 then alg(s, PSVec.empty[A]) - else - @tailrec def loop(i: Int): Unit = - if i < k then - val child = arr(i).asInstanceOf[S] - arr(i) = foldInPlaceRec(childrenOf, alg, child, depth + 1) - loop(i + 1) - loop(0) - alg(s, PSVec.unsafeWrap[A](arr)) - - /** Heap trampoline for [[foldInPlace]]: the [[unfoldCoalgHeap]] walk specialised to overwrite - * each child's slot in the owned array with its fold result (no separate out-array). `enter` and - * the `@tailrec loop` driver share the one `stack` + `ret` cell; `loop`'s self-call stays in - * tail position for stack-safety. - */ - private def foldInPlaceHeap[S, A]( - childrenOf: S => Array[Any], - alg: (S, PSVec[A]) => A, - root: S, - ): A = - final class Frame(val node: S, val arr: Array[Any], var i: Int) - val stack = new ArrayDeque[Frame]() - var ret: Any = null - def enter(s: S): Unit = - val arr = childrenOf(s) - if arr.length == 0 then ret = alg(s, PSVec.empty[A]) - else stack.push(new Frame(s, arr, 0)) - enter(root) - @tailrec def loop(): A = - if stack.isEmpty then ret.asInstanceOf[A] - else - val fr = stack.peek() - if fr.i > 0 then fr.arr(fr.i - 1) = ret // overwrite the just-folded child's slot - if fr.i < fr.arr.length then - val child = fr.arr(fr.i).asInstanceOf[S] - fr.i += 1 - enter(child) - else - ret = alg(fr.node, PSVec.unsafeWrap[A](fr.arr)) - val _ = stack.pop() - loop() - loop() - - /** Catamorphism as a composable `Getter`, driven by `Plated[S]`. The algebra sees the original - * node `S` (paramorphism-flavored) plus its already-folded children. + /** The single *layer* optic for a pattern functor `F`: `project`/`embed` worn as core's + * [[dev.constructive.eo.data.MultiFocus]] carrier — `MultiFocus[F][X, A] = (X, F[A])`, the + * Traversal/AlgLens/Grate carrier. `to(s) = ((), project(s))` and `from((_, fs)) = embed(fs)`, + * so it is a genuine `Optic[S, S, S, S, MultiFocus[F]]`: a **typed single-layer self-traversal** + * whose foci are the node's immediate children `F[S]`. * - * Stack-safety contract: below the 512-frame on-stack limit the fold recurses directly on the - * JVM stack (no heap frames); past it, each deep subtree is handed to a heap `ArrayDeque` - * machine — so any *finite* tree folds without `StackOverflowError`, at any depth, and a - * `Plated` whose children never bottom out fails by exhausting the heap (`OutOfMemoryError`), - * not the stack. Folds child results in place (see the private `foldInPlace` engine) so it - * allocates one array per node, not two. - */ - def cata[S, A](alg: (S, PSVec[A]) => A)(using P: Plated[S]): Getter[S, A] = - Getter[S, A](foldInPlace[S, A](P.childrenArray, alg)) - - /** Catamorphism from a build-only optic citizen: a *pure* algebra `PSVec[A] => A` carried as an - * [[dev.constructive.eo.optics.Unfold]], so an algebra built by optic composition - * (`review.andThen(unfold)`, `unfold.andThen(review)`) drops straight into the fold engine. - * - * Note the honesty limit of the untyped path: a `PSVec` layer is node-blind, so a pure - * `PSVec[A] => A` can express only constructor-independent folds (`size`, child counts, …) — - * `eval`-style algebras need the para-flavored `(S, PSVec[A]) => A` overload above. A typed - * pattern-functor path — where a pure `F[A] => A` algebra would be fully expressive, because - * `F`'s constructors carry what `PSVec` erases — is ''planned'' but not part of this artifact - * yet: there is no `cataF` entry point to look for. - */ - def cata[S, A](alg: Unfold[A, A, PSVec])(using Plated[S]): Getter[S, A] = - cata[S, A]((_, kids) => alg.embed(kids)) - - /** Anamorphism as a `Review` (reverse-construction optic): a stack-safe unfold `Seed => S` driven - * by a [[Coalg]]. Materializing — the built `S` is `O(nodes)`. + * Because it now rides the same carrier as [[dev.constructive.eo.optics.Plated.plate]] and + * `dev.constructive.eo.optics.Traversal.each`, it composes with the rest of core: read the + * immediate foci via `.foldMap` (`Foldable[F]`), rewrite them via `.modify` / `.replace` + * (`Functor[F]`), or effect over them via `.modifyA` / `.all` (`Traverse[F]`) — the read+write + * upgrade over the former read-only `Forget[F]` spelling. It is one layer, not the recursion; + * the `Plated.fromBasis` derivation is its recursive face, and the schemes drive `to`/`from` + * themselves. `X = Unit`: the `F`-shape (constructor + arity) rides inside the foci `F[S]`, so + * `embed` needs no extra leftover. + */ + def fLayer[F[_], S](using Project[F, S], Embed[F, S]): Optic[S, S, S, S, MultiFocus[F]] = + new FLayer[F, S] + + /** The named class behind [[fLayer]] — the single-layer peel/glue self-traversal. */ + final private class FLayer[F[_], S](using P: Project[F, S], E: Embed[F, S]) + extends Optic[S, S, S, S, MultiFocus[F]]: + type X = Unit + def to(s: S): MultiFocus[F][X, S] = MultiFocus((), P.project(s)) + def from(pair: MultiFocus[F][X, S]): S = E.embed(pair.foci) + + // ===== Folds =============================================================================== + + /** Catamorphism — a node-blind fold `alg: F[A] => A` ([[zoo.Cata]], `X = Nothing`). `.get`. */ + def cata[F[_], S, A](alg: F[A] => A)(using Traverse[F], Project[F, S]): Cata[F, S, A] = + new Cata[F, S, A](alg) + + /** Paramorphism — a subterm-retaining fold `alg: F[(S, A)] => A` ([[zoo.Para]], `X = F[(S, A)]`). + * `.get`. Ignoring the `S` half degenerates to [[cata]]. + */ + def para[F[_], S, A](alg: F[(S, A)] => A)(using Traverse[F], Project[F, S]): Para[F, S, A] = + new Para[F, S, A](alg) + + /** Histomorphism — a course-of-value fold `alg: F[Attr[F, A]] => A` ([[zoo.Histo]], `X = Attr`, + * the cofree comonad). `.get`. Heads-only degenerates to [[cata]]. + */ + def histo[F[_], S, A](alg: F[Attr[F, A]] => A)(using Traverse[F], Project[F, S]): Histo[F, S, A] = + new Histo[F, S, A](alg) + + /** Zygomorphism — a fold with an **auxiliary algebra** `aux: F[B] => B` feeding the main `alg: + * F[(B, A)] => A` ([[zoo.Zygo]], `X = F[(B, A)]`). The comonad-tower rung between [[cata]] and + * [[para]]: `para` is `zygo` at `B = S`, `aux = embed`; ignoring the `B` half degenerates to + * [[cata]]. `.get`. + */ + def zygo[F[_], S, A, B](aux: F[B] => B)(alg: F[(B, A)] => A)(using + Traverse[F], + Project[F, S], + ): Zygo[F, S, A, B] = new Zygo[F, S, A, B](aux, alg) + + /** Mutumorphism — a fold by **mutual recursion**: two algebras `F[(A, B)] => A` / + * `F[(A, B)] => B` compute a pair per node, returning the `A` half ([[zoo.Mutu]], + * `X = F[(A, B)]`). Generalises [[zygo]] (whose `aux` is an `algB` blind to the `A` half). + * `.get`. + */ + def mutu[F[_], S, A, B](algA: F[(A, B)] => A, algB: F[(A, B)] => B)(using + Traverse[F], + Project[F, S], + ): Mutu[F, S, A, B] = new Mutu[F, S, A, B](algA, algB) + + // ===== Unfolds ============================================================================= + + /** Anamorphism — an unfold `coalg: Seed => F[Seed]` ([[zoo.Ana]], `X = S`). `.reverseGet`. */ + def ana[F[_], Seed, S](coalg: Seed => F[Seed])(using Traverse[F], Embed[F, S]): Ana[F, Seed, S] = + new Ana[F, Seed, S](coalg) + + /** Apomorphism — an unfold that grafts finished subtrees, `coalg: A => F[Either[S, A]]` + * ([[zoo.Apo]], `X = Either[S, A]`). `Left` grafts by reference (O(1)). `.reverseGet`. + * All-`Right` degenerates to [[ana]]. + */ + def apo[F[_], A, S](coalg: A => F[Either[S, A]])(using Traverse[F], Embed[F, S]): Apo[F, A, S] = + new Apo[F, A, S](coalg) + + /** [[apo]]'s per-slot residual worn on the [[data.Affine]] build seam — a composable *scatter* + * optic (`Left(s) → Done(s)` the O(1) graft, `Right(a) → Step((), a)` keep unfolding). `X = (S, + * Unit)`. Composes via [[data.Affine.assoc]] and the [[data.Affine.either2affine]] bridge; it is + * the carried decoration [[apo]]'s engine itself drives (see [[zoo.Apo]]). + */ + def apoScatter[S, A]: Optic[Either[S, A], Unit, A, Unit, Affine] { type X = (S, Unit) } = + Apo.scatter[S, A] + + /** Futumorphism — a multi-layer unfold `coalg: A => F[Coattr[F, A]]` ([[zoo.Futu]], `X = Coattr`, + * the free monad). `.reverseGet`. All-`Pure` degenerates to [[ana]]. + */ + def futu[F[_], A, S](coalg: A => F[Coattr[F, A]])(using Traverse[F], Embed[F, S]): Futu[F, A, S] = + new Futu[F, A, S](coalg) + + /** Cozygomorphism (g-apomorphism) — the build-side dual of [[zygo]]: an **auxiliary coalgebra** + * `aux: B => F[B]` alongside the main `coalg: A => F[Either[B, A]]` ([[zoo.Cozygo]], `X = + * Either[B, A]`). `Left(b)` keeps unfolding through `aux`; all-`Right` degenerates to [[ana]]. + * `.reverseGet`. + */ + def cozygo[F[_], A, B, S](aux: B => F[B])(coalg: A => F[Either[B, A]])(using + Traverse[F], + Embed[F, S], + ): Cozygo[F, A, B, S] = new Cozygo[F, A, B, S](aux, coalg) + + /** Comutumorphism — the build-side dual of [[mutu]]: two mutually co-recursive coalgebras + * `A => F[Either[A, B]]` / `B => F[Either[A, B]]`, entered at an `A` ([[zoo.Comutu]], `X = + * Either[A, B]`). Generalises [[cozygo]]; degenerates to [[ana]] when one coalgebra suffices. + * `.reverseGet`. + */ + def comutu[F[_], A, B, S](coalgA: A => F[Either[A, B]], coalgB: B => F[Either[A, B]])(using + Traverse[F], + Embed[F, S], + ): Comutu[F, A, B, S] = new Comutu[F, A, B, S](coalgA, coalgB) + + // ===== Refolds (fused — Traverse[F] only, no intermediate S) =============================== + + /** Hylomorphism — the fused unfold→fold `Seed => A` ([[zoo.Hylo]]). Definitionally + * `ana(coalg).cross(cata(alg))`. + */ + def hylo[F[_], Seed, A](coalg: Seed => F[Seed], alg: F[A] => A)(using + Traverse[F] + ): Hylo[Seed, A] = + Hylo(coalg, alg) + + /** Dynamorphism — the fused plain-unfold → cofree-fold `A => B` ([[zoo.Dyna]]). Definitionally + * `ana(coalg).cross(histo(alg))`. + */ + def dyna[F[_], A, B](coalg: A => F[A], alg: F[Attr[F, B]] => B)(using Traverse[F]): Dyna[A, B] = + Dyna(coalg, alg) + + /** Codynamorphism — the fused free-unfold → node-blind-fold `A => B` ([[zoo.Codyna]], the mirror + * of [[dyna]]). Definitionally `futu(coalg).cross(cata(alg))`. + */ + def codyna[F[_], A, B](coalg: A => F[Coattr[F, A]], alg: F[B] => B)(using + Traverse[F] + ): Codyna[A, B] = Codyna(coalg, alg) + + /** Chronomorphism — the fused free-unfold → cofree-fold `A => B` ([[zoo.Chrono]]), [[hylo]] at + * the universal indices. Definitionally `futu(coalg).cross(histo(algebra))`. + */ + def chrono[F[_], A, B](coalg: A => F[Coattr[F, A]], algebra: F[Attr[F, B]] => B)(using + Traverse[F] + ): Chrono[A, B] = Chrono(coalg, algebra) + + /** Elgot — a [[hylo]] whose unfold may short-circuit, `coalg: A => Either[B, F[A]]` + * ([[zoo.Elgot]]). All-`Right` degenerates to [[hylo]]. + */ + def elgot[F[_], A, B](coalg: A => Either[B, F[A]], alg: F[B] => B)(using + Traverse[F] + ): Elgot[A, B] = + Elgot(coalg, alg) + + /** Co-Elgot — a [[hylo]] whose fold reads the seed, `alg: (A, F[B]) => B` ([[zoo.Coelgot]]). + * Ignoring the seed degenerates to [[hylo]]. + */ + def coelgot[F[_], A, B](coalg: A => F[A], alg: (A, F[B]) => B)(using + Traverse[F] + ): Coelgot[A, B] = Coelgot(coalg, alg) + + // ===== Metamorphisms (fold→unfold — do NOT fuse; keep both Bases) ========================== + + /** Metamorphism — the fold-then-unfold `S => T` ([[zoo.Meta]], `X = A`, the neck). Fold the + * `F`-recursive `S` to `A`, then unfold a `G`-recursive `T`. Definitionally `cata(alg).meta( + * ana(coalg))`. + */ + def meta[F[_], S, A, G[_], T](alg: F[A] => A, coalg: A => G[A])(using + Traverse[F], + Project[F, S], + Traverse[G], + Embed[G, T], + ): Meta[S, A, T] = Meta(alg, coalg) + + /** Metamorphism at the universal indices — the fold→unfold dual of [[chrono]] + * ([[zoo.MetaChrono]]): course-of-value fold then multi-layer unfold. Definitionally + * `histo(algebra).meta(futu(coalg))`. + */ + def metaChrono[F[_], S, A, G[_], T]( + algebra: F[Attr[F, A]] => A, + coalg: A => G[Coattr[G, A]], + )(using Traverse[F], Project[F, S], Traverse[G], Embed[G, T]): MetaChrono[S, A, T] = + MetaChrono(algebra, coalg) + + // ===== Layer-transforming schemes (the natural-transformation axis) ======================== + // Orthogonal to the (co)monad index towers: these keep the trivial index and instead pre/post- + // compose the layer optic (fLayer) with an accumulating natural transformation η : F ~> F. + + /** Prepromorphism — a [[cata]]-shaped fold (`alg: F[A] => A`, `X = Nothing`) that applies a + * natural transformation **`η : F ~> F` before recursing**, so a node at depth `k` sees `η` + * applied `k` times ([[zoo.Prepro]]). `η = id` degenerates to [[cata]]. `.get`. `O(n · depth)`. + */ + def prepro[F[_], S, A](eta: F ~> F)(alg: F[A] => A)(using + Traverse[F], + Project[F, S], + Embed[F, S], + ): Prepro[F, S, A] = new Prepro[F, S, A](eta, alg) + + /** Postpromorphism — the build-side mirror of [[prepro]]: an [[ana]]-shaped unfold (`coalg: A => + * F[A]`, `X = S`) that applies **`η : F ~> F` after each step** ([[zoo.Postpro]]). `η = id` + * degenerates to [[ana]]. `.reverseGet`. `O(n · depth)`. + */ + def postpro[F[_], A, S](eta: F ~> F)(coalg: A => F[A])(using + Traverse[F], + Project[F, S], + Embed[F, S], + ): Postpro[F, A, S] = new Postpro[F, A, S](eta, coalg) + + // ===== Monadic schemes (effects sequenced through the recursion) =========================== + // The `*M` family lifts the zoo into a `Monad[M]` via the single [[Machines.foldLayeredM]] + // engine. NOT a parallel class hierarchy: all read-side variants are [[zoo.FoldM]], all + // build-side ones [[zoo.BuildM]] (the index rides each citizen's phantom `XI`). The layer + // adapters mirror the pure side's — [[liftProject]] / [[liftCoalg]] / [[embedM]] lift the + // `project` / `coalg` / `embed` wiring into the engine's `Or`-shape, and [[zoo.Coattr.expandM]] / + // [[zoo.Attr.decorateM]] are the M-twins of `Coattr.expand` / `Attr.decorate` — so each `*M` + // factory reads as terse as its pure twin. `M` must be single-pass / linear / sequential (`Id`, + // `Eval`, `State`, `IO`); a branching/replaying `M` corrupts the engine's mutable walk state. + + // The fold-side expand: lift a pure `project` into the engine's `N => M[Either[R, F[N]]]` + // (always `Right` — a fold never grafts; `R` is phantom). + private def liftProject[M[_], F[_], S, R](project: S => F[S])(using + M: Monad[M] + ): S => M[Either[R, F[S]]] = s => M.pure(Right(project(s))) + + // The build-side expand: lift an effectful `coalg` the same way (always `Right`; `R` phantom). + private def liftCoalg[M[_], F[_], A, R](coalg: A => M[F[A]])(using + M: Monad[M] + ): A => M[Either[R, F[A]]] = a => M.map(coalg(a))(Right(_)) + + // The build-side combine: glue a rebuilt layer back with `Embed`, lifted into `M`. + private def embedM[M[_], F[_], S, N](using M: Monad[M], E: Embed[F, S]): (N, F[S]) => M[S] = + (_, fr) => M.pure(E.embed(fr)) + + /** Monadic catamorphism — a node-blind fold `alg: F[A] => M[A]` ([[zoo.FoldM]], `X = Nothing`). + * `.get` yields `M[A]`. At `M = Id` it is exactly [[cata]]. + */ + def cataM[M[_], F[_], S, A](alg: F[A] => M[A])(using + M: Monad[M], + F: Traverse[F], + P: Project[F, S], + ): FoldM[S, A, M, Nothing] = + FoldM(Machines.foldLayeredM[M, F, S, A](liftProject(P.project), (_, fr) => alg(fr))) + + /** Monadic paramorphism — a subterm-retaining effectful fold `alg: F[(S, A)] => M[A]` + * ([[zoo.FoldM]], `X = F[(S, A)]`). `.get` yields `M[A]`. + */ + def paraM[M[_], F[_], S, A](alg: F[(S, A)] => M[A])(using + M: Monad[M], + F: Traverse[F], + P: Project[F, S], + ): FoldM[S, A, M, F[(S, A)]] = + FoldM( + Machines.foldLayeredM[M, F, S, A]( + liftProject(P.project), + (s, fa) => + val it = F.toList(fa).iterator + alg(F.map(P.project(s))(sub => (sub, it.next()))), + ) + ) + + /** Monadic histomorphism — a course-of-value effectful fold `alg: F[Attr[F, A]] => M[A]` + * ([[zoo.FoldM]], `X = Attr[F, A]`, the cofree comonad). `.get` yields `M[A]`. + */ + def histoM[M[_], F[_], S, A](alg: F[Attr[F, A]] => M[A])(using + M: Monad[M], + F: Traverse[F], + P: Project[F, S], + ): FoldM[S, A, M, Attr[F, A]] = + val toAttr = + Machines.foldLayeredM[M, F, S, Attr[F, A]](liftProject(P.project), Attr.decorateM(alg)) + FoldM(s => M.map(toAttr(s))(Attr.forget)) + + /** Monadic anamorphism — an effectful unfold `coalg: Seed => M[F[Seed]]` ([[zoo.BuildM]], `X = + * S`). `.reverseGet` yields `M[S]`. At `M = Id` it is exactly [[ana]]. + */ + def anaM[M[_], F[_], Seed, S](coalg: Seed => M[F[Seed]])(using + M: Monad[M], + F: Traverse[F], + E: Embed[F, S], + ): BuildM[S, Seed, M, S] = + BuildM(Machines.foldLayeredM[M, F, Seed, S](liftCoalg(coalg), embedM)) + + /** Monadic apomorphism — an effectful grafting unfold `coalg: A => M[F[Either[S, A]]]` + * ([[zoo.BuildM]], `X = Either[S, A]`). `Left(s)` grafts a finished subtree by reference (O(1), + * no effect). `.reverseGet` yields `M[S]`. + */ + def apoM[M[_], F[_], A, S](coalg: A => M[F[Either[S, A]]])(using + M: Monad[M], + F: Traverse[F], + E: Embed[F, S], + ): BuildM[S, A, M, Either[S, A]] = + val run = Machines.foldLayeredM[M, F, Either[S, A], S]( + { + case Left(s) => M.pure(Left(s)) + case Right(a) => M.map(coalg(a))(Right(_)) + }, + embedM, + ) + BuildM(a => run(Right(a))) + + /** Monadic futumorphism — an effectful multi-layer unfold `coalg: A => M[F[Coattr[F, A]]]` + * ([[zoo.BuildM]], `X = Coattr[F, A]`, the free monad). `Roll` unrolls a prebuilt layer with no + * effect. `.reverseGet` yields `M[S]`. + */ + def futuM[M[_], F[_], A, S](coalg: A => M[F[Coattr[F, A]]])(using + M: Monad[M], + F: Traverse[F], + E: Embed[F, S], + ): BuildM[S, A, M, Coattr[F, A]] = + val run = Machines.foldLayeredM[M, F, Coattr[F, A], S](Coattr.expandM(coalg), embedM) + BuildM(a => run(Coattr.Pure(a))) + + /** Monadic hylomorphism — the fused effectful refold `Seed => M[A]` ([[zoo.FoldM]], `X = + * Nothing`), building **no intermediate `S`**. `Traverse[F]` only. At `M = Id` it is [[hylo]]. + */ + def hyloM[M[_], F[_], Seed, A](coalg: Seed => M[F[Seed]], alg: F[A] => M[A])(using + M: Monad[M], + F: Traverse[F], + ): FoldM[Seed, A, M, Nothing] = + FoldM(Machines.foldLayeredM[M, F, Seed, A](liftCoalg(coalg), (_, fr) => alg(fr))) + + /** Monadic chronomorphism — the fused effectful free-unfold → cofree-fold `A => M[B]` + * ([[zoo.FoldM]], `X = Nothing`), [[hyloM]] at the universal indices. `Traverse[F]` only. + */ + def chronoM[M[_], F[_], A, B]( + coalg: A => M[F[Coattr[F, A]]], + alg: F[Attr[F, B]] => M[B], + )(using M: Monad[M], F: Traverse[F]): FoldM[A, B, M, Nothing] = + val build = + Machines.foldLayeredM[M, F, Coattr[F, A], Attr[F, B]]( + Coattr.expandM(coalg), + Attr.decorateM(alg), + ) + FoldM(a => M.map(build(Coattr.Pure(a)))(Attr.forget)) + + // ===== Writable scheme — the paramorphism as a Lens ======================================== + + /** The paramorphism promoted from a Getter to a **Lens** — [[para]] is the read (`get`), this + * adds the write (`enplace`), yielding a core [[dev.constructive.eo.optics.GetReplaceLens]] that + * composes with hand-written / derived Lenses on the fused `Tuple2` path. * - * Stack-safety contract: below the 512-frame on-stack limit the unfold recurses directly on the - * JVM stack; past it, each deep subtree is handed to a heap `ArrayDeque` machine — so any - * ''terminating'' coalgebra builds without `StackOverflowError`, at any depth, and a - * non-terminating one fails by exhausting the heap (`OutOfMemoryError`), not the stack. - */ - def ana[Seed, S](coalg: Coalg[Seed, S]): Review[S, Seed] = - Review[S, Seed](unfoldCoalg(coalg)) - - /** Hylomorphism — the **fused** refold `Seed => A`, building **no intermediate `S`**: `expand` - * unfolds seeds and `alg` folds to `A` in one post-order pass. Returned as a `Getter[Seed, A]` - * so it composes further. Equal to `ana(…).cross(cata(alg))` on the same computation (the hylo - * law), but without materializing the structure. + * '''Why `enplace` is a parameter, not derived.''' `para` is *unconditionally* a Getter, but a + * fold result is not in general a recoverable component of `S`. The automatic put the + * store-comonad story suggests — re-embed the retained subterms (`X = F[(S, A)]`) — makes + * **get-put** hold definitionally yet leaves **put-get** conditional on the algebra having a + * coherent inverse. Rather than ship a `Lens` that is only conditionally lawful, the coherent + * put-direction is supplied by the caller; `get` / `enplace` then obey the ordinary Lens laws. + * (The fully-automatic, read-only decorated optic — each child paired with its fold result, + * `X = F[(S, A)]`, the store comonad over subterms — is the natural enrichment of [[fLayer]] and + * a follow-up; it stays read-only because the decoration cannot be lawfully written.) * - * Stack-safety contract (same as [[cata]] / [[ana]]): on-stack recursion below the 512-frame - * limit, then a heap `ArrayDeque` machine per deep subtree — safe for any ''terminating'' - * `expand` at any depth; a non-terminating `expand` fails by exhausting the heap - * (`OutOfMemoryError`), not by `StackOverflowError`. - */ - def hylo[Seed, A]( - expand: Seed => PSVec[Seed], - alg: (Seed, PSVec[A]) => A, - ): Getter[Seed, A] = - // Routed through the one Coalg engine — B/op-checked vs the dedicated - // unfoldFold engine it replaced (SchemesBench.eoHylo, -prof gc). - Getter[Seed, A](unfoldCoalg[Seed, A](seed => (expand(seed), rs => alg(seed, rs)))) + * @param alg + * the subterm-retaining fold `F[(S, A)] => A` — the `get`, a genuine paramorphism. + * @param enplace + * the coherent put: rebuild an `S` whose `get` is the new focus. + */ + def paraLens[F[_], S, A](alg: F[(S, A)] => A)(enplace: (S, A) => S)(using + Traverse[F], + Project[F, S], + ): GetReplaceLens[S, S, A, A] = + val fold = para(alg) + Lens(fold.get(_), enplace) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala new file mode 100644 index 00000000..15816233 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala @@ -0,0 +1,34 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Anamorphism citizen — an unfold ([[BuildScheme]]) with `X = S` (the structure it threads). + * Carries `coalg` so [[cross]] can fuse with a node-blind [[Cata]] (→ [[Hylo]]) or a + * course-of-value [[Histo]] (→ [[Dyna]]). Refining `X` upward to [[Coattr]] = `μX. Seed + F[X]` + * (the free monad) gives the multi-layer unfold (futumorphism — [[Futu]]). + */ +final class Ana[F[_], Seed, S](private[zoo] val coalg: Seed => F[Seed])(using + F: Traverse[F], + E: Embed[F, S], +) extends BuildScheme[S, Seed]: + type X = S + + private[zoo] val build: Seed => S = + Machines.buildLayered[F, Seed, S](coalg) + + protected def write(seed: Seed): S = build(seed) + + /** The fused **hylo** seam: ana ∘ a node-blind [[Cata]]. Because the fold retains nothing (`X = + * Nothing`), deforestation is sound — the one-pass machine is rebuilt from `coalg` + `cata.alg`, + * building **no intermediate `S`**. A member (not the generic `Optic.cross`, which would + * `reverse.andThen` into a materialising read) so the fusion wins overload resolution. + */ + def cross[B](cata: Cata[F, S, B]): Hylo[Seed, B] = Hylo[F, Seed, B](coalg, cata.alg) + + /** The fused **dynamorphism** seam: plain unfold ∘ course-of-value fold ([[Histo]]) — the + * refold-quadrant diagonal between [[cross]]'s `hylo` and [[Futu.cross]]'s `chrono`. Fuses; the + * [[Attr]] cofree memo is threaded internally, no intermediate `S`. + */ + def cross[B](histo: Histo[F, S, B]): Dyna[Seed, B] = Dyna[F, Seed, B](coalg, histo.alg) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala new file mode 100644 index 00000000..86963b41 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala @@ -0,0 +1,68 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import data.Affine +import optics.Optic + +/** Apomorphism citizen — an unfold that may **short-circuit with a finished subtree** + * ([[BuildScheme]]) with **`X = Either[S, A]`** (the residual): each child slot is either + * `Left(s)` — an already-built `S`, grafted in directly — or `Right(a)` — a seed to keep + * unfolding. + * + * `coalg: A => F[Either[S, A]]` is the build-side dual of [[Para]]'s read-side subterm retention: + * where para *reads* original subterms, apo *writes* finished ones. The `Either` residual is the + * Prism's match worn build-side. An all-`Right` coalgebra degenerates to [[Ana]]. + * + * '''The residual is a [[data.Affine]] optic.''' apo's per-slot decision is exactly `Affine`'s + * build seam — `Left(s)` is `Miss(s)` (a finished slot, the O(1) graft), `Right(a)` is `Hit((), + * a)` (keep unfolding). [[Apo.scatter]] exposes that decision as a composable `Affine`-carried + * optic (a *scatter*), and this engine constructs and consumes it through that optic: every slot + * goes `residual → scatter.to → Miss/Hit → engine`, so apo genuinely speaks the carrier the + * carrier was written for. The pure [[Machines.foldLayeredOr]] engine still recurses over an + * `Either` at its boundary (it is shared with elgot/cozygo); the `Miss`/`Hit` decision is + * collapsed onto that boundary at the last step. + * + * '''O(1) graft.''' A `Miss(s)` subtree is placed into its result slot **by reference** — the + * engine's `Left` arm returns it without recursing or re-`project`ing. Stack-safe. + */ +final class Apo[F[_], A, S](private[zoo] val coalg: A => F[Either[S, A]])(using + F: Traverse[F], + E: Embed[F, S], +) extends BuildScheme[S, A]: + type X = Either[S, A] + + private val build: A => S = + val sc = Apo.scatter[S, A] + val run = Machines.foldLayeredOr[F, Either[S, A], S]( + residual => + sc.to(residual) + .fold[Either[S, F[Either[S, A]]]]( + s => Left(s), // Miss — finished subtree, grafted by reference (O(1)) + (_, a) => Right(coalg(a)), // Hit — seed, keep unfolding + ), + fr => E.embed(fr), + ) + a => run(Right(a)) + + protected def write(a: A): S = build(a) + +object Apo: + + /** apo's per-slot residual worn on the [[data.Affine]] build seam — a *scatter* decoration. + * `Left(s) → Miss(s)` (the O(1) graft); `Right(a) → Hit((), a)` (keep unfolding). The + * existential is pinned `X = (S, Unit)`: `Fst[X] = S` is the grafted subtree, `Snd[X] = Unit` (a + * single slot decision carries no extra one-layer leftover). As a genuine `Optic[…, Affine]` + * value it composes via [[data.Affine.assoc]] and the [[data.Affine.either2affine]] bridge (so a + * Prism whose focus is the residual composes straight into it). The `X` is exposed (refined) so + * `Fst[X]` reduces at use sites. + */ + def scatter[S, A]: Optic[Either[S, A], Unit, A, Unit, Affine] { type X = (S, Unit) } = + new Optic[Either[S, A], Unit, A, Unit, Affine]: + type X = (S, Unit) + def to(e: Either[S, A]): Affine[X, A] = e match + case Left(s) => new Affine.Miss[X](s) + case Right(a) => new Affine.Hit[X, A]((), a) + def from(b: Affine[X, Unit]): Unit = () diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Attr.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Attr.scala new file mode 100644 index 00000000..ec0821d7 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Attr.scala @@ -0,0 +1,36 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Monad + +/** Cofree-without-laziness: a fold result (`head`) decorating one layer of already-decorated + * children (`tail`). The histomorphism's algebra sees `F[Attr[F, A]]` — each child's result *plus* + * that child's entire decorated history. + * + * @tparam F + * the pattern functor + * @tparam A + * the fold result decorating each node + */ +final case class Attr[F[_], A](head: A, tail: F[Attr[F, A]]) + +object Attr: + + /** Discard the history, keep the top result — `histo`'s final projection. */ + def forget[F[_], A](attr: Attr[F, A]): A = attr.head + + /** The cofree-decorating combine shared by `histo` / `dyna` / `chrono` / `metaChrono`: tag each + * rebuilt layer `F[Attr[F, A]]` with its algebra result, yielding the node's `Attr` (head = + * result, tail = the decorated layer). The node argument is unused — the algebra is node-blind. + */ + def decorate[F[_], N, A](alg: F[Attr[F, A]] => A): (N, F[Attr[F, A]]) => Attr[F, A] = + (_, layer) => Attr(alg(layer), layer) + + /** The effectful cofree-decorating combine shared by `histoM` / `chronoM` — the M-lifted + * [[decorate]]: run the effectful algebra on the rebuilt layer, tag the result onto it. + */ + def decorateM[M[_], F[_], N, A](alg: F[Attr[F, A]] => M[A])(using + M: Monad[M] + ): (N, F[Attr[F, A]]) => M[Attr[F, A]] = + (_, layer) => M.map(alg(layer))(a => Attr(a, layer)) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/BuildM.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/BuildM.scala new file mode 100644 index 00000000..1424a67c --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/BuildM.scala @@ -0,0 +1,32 @@ +package dev.constructive.eo +package schemes +package zoo + +/** Monadic-build citizen — the **effectful** unfold schemes ([[BuildScheme]] at carrier `M[S]`): an + * unfold whose coalgebra returns its layer in `M` and whose effects are sequenced through the + * construction. Builds `B => M[S]` (`.reverseGet` yields `M[S]`). + * + * The build-side mirror of [[FoldM]]: one class carries the whole `M`-unfold zoo — + * [[Schemes.anaM]] / [[Schemes.apoM]] / [[Schemes.futuM]] — differing only by the expand handed to + * the shared engine. The residual index rides the phantom `XI` (`anaM` is `BuildM[…, S]`, `apoM` + * is `BuildM[…, Either[S, A]]`, `futuM` is `BuildM[…, Coattr[F, A]]`), preserving the monad-tower + * index the same way [[FoldM]] preserves the comonad-tower one. + * + * Runs on [[Machines.foldLayeredM]] under the same single-pass / linear / sequential `M` contract + * as [[FoldM]]. + * + * @tparam XI + * the residual index this unfold threads — the optic existential `X`, carried as a type + * parameter so one class spans the whole build-side `M`-zoo. + */ +final class BuildM[S, B, M[_], XI] private[zoo] (run: B => M[S]) extends BuildScheme[M[S], B]: + type X = XI + protected def write(b: B): M[S] = run(b) + +object BuildM: + + /** Wrap an already-wired effectful unfold `B => M[S]`. The engine plumbing lives in the + * [[Schemes]] `*M` factories (each picks the expand and pins `XI`). + */ + private[schemes] def apply[S, B, M[_], XI](run: B => M[S]): BuildM[S, B, M, XI] = + new BuildM[S, B, M, XI](run) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala new file mode 100644 index 00000000..31a1852b --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala @@ -0,0 +1,31 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Catamorphism citizen — a **node-blind** fold ([[ReadScheme]]) with `X = Nothing`, the forgetful + * (trivial) resolution of the recursion index. Carries `alg` so [[Ana.cross]] can rebuild the + * fused [[Hylo]] machine. + * + * `alg: F[A] => A` sees only the already-folded children (named constructors), never the source + * node — that blindness (`X = Nothing`) is the soundness condition that licenses fusion. Refining + * `X` upward gives the richer folds: `F[(S, A)]` (paramorphism, [[Para]]) and [[Attr]] = + * `νX. A × F[X]` (histomorphism, the cofree comonad — [[Histo]]). + */ +final class Cata[F[_], S, A](private[zoo] val alg: F[A] => A)(using + F: Traverse[F], + P: Project[F, S], +) extends ReadScheme[S, A]: + type X = Nothing + + private val run: S => A = Machines.foldLayered[F, S, A](P.project, (_, fr) => alg(fr)) + protected def read(s: S): A = run(s) + + /** Metamorphism — the fold→unfold seam, **dual to [[Ana.cross]]**'s unfold→fold. Fold `this` to + * the neck `A`, then unfold it with `ana` into a fresh `G`-recursive `T`. **Does not fuse**: + * fold over `F`, unfold over a possibly-different `G`, so the neck is materialised (the [[Meta]] + * `X = A`). + */ + def meta[G[_], T](ana: Ana[G, A, T]): Meta[S, A, T] = + new Meta[S, A, T](run.andThen(ana.build)) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Chrono.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Chrono.scala new file mode 100644 index 00000000..90445720 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Chrono.scala @@ -0,0 +1,30 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Chronomorphism citizen — the **fused** futu-then-histo refold ([[ReadScheme]]), [[Hylo]] lifted + * to the universal indices: unfold through the free monad ([[Coattr]]), fold through the cofree + * comonad ([[Attr]]), no intermediate `S`. Built by [[Futu.cross]] or [[Chrono.apply]]. A + * nominally-distinct member of the fused-refold family (see [[Hylo]]): honest `X = Nothing`. + */ +final class Chrono[A, B] private[zoo] (private val refold: A => B) extends ReadScheme[A, B]: + type X = Nothing + protected def read(a: A): B = refold(a) + +object Chrono: + + /** The fused free→cofree refold `A => B`, `Traverse[F]` only. Heads-only `algebra` + all-`Pure` + * `coalg` degenerate to [[Hylo]]. Stack-safe; retains O(n) `Attr` cells by nature. + */ + def apply[F[_], A, B]( + coalg: A => F[Coattr[F, A]], + algebra: F[Attr[F, B]] => B, + )(using F: Traverse[F]): Chrono[A, B] = + val build: Coattr[F, A] => Attr[F, B] = + Machines.foldLayered[F, Coattr[F, A], Attr[F, B]]( + Coattr.expand(coalg), + Attr.decorate(algebra), + ) + new Chrono[A, B](a => Attr.forget(build(Coattr.Pure(a)))) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coattr.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coattr.scala new file mode 100644 index 00000000..51555f3c --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coattr.scala @@ -0,0 +1,42 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Monad + +/** Free-without-suspension: a futumorphism's coalgebra answers each slot with either a seed still + * to expand ([[Coattr.Pure]]) or an already-known layer to unroll without consulting the coalgebra + * again ([[Coattr.Roll]]) — the multi-layer-per-step channel. + * + * @tparam F + * the pattern functor + * @tparam A + * the seed type + */ +enum Coattr[F[_], A]: + + /** A seed — the engine calls the coalgebra on it. */ + case Pure(a: A) + + /** A prebuilt layer — unrolled directly, no coalgebra call for this layer. */ + case Roll(layer: F[Coattr[F, A]]) + +object Coattr: + + /** The futumorphic expand step shared by `futu` / `chrono` / `codyna` / `metaChrono`: turn a + * coalgebra `A => F[Coattr[F, A]]` into the engine's layer producer over `Coattr` — `Pure` calls + * the coalgebra, `Roll` unrolls a prebuilt layer with no coalgebra call. + */ + def expand[F[_], A](coalg: A => F[Coattr[F, A]]): Coattr[F, A] => F[Coattr[F, A]] = + case Pure(a) => coalg(a) + case Roll(layer) => layer + + /** The effectful expand step shared by `futuM` / `chronoM` — the M-lifted [[expand]] worn in the + * `Or`-shape [[Machines.foldLayeredM]] consumes (always `Right`; futu never grafts). `Pure` runs + * the effectful coalgebra, `Roll` unrolls a prebuilt layer purely (`M.pure`). + */ + def expandM[M[_], F[_], A, R](coalg: A => M[F[Coattr[F, A]]])(using + M: Monad[M] + ): Coattr[F, A] => M[Either[R, F[Coattr[F, A]]]] = + case Pure(a) => M.map(coalg(a))(Right(_)) + case Roll(layer) => M.pure(Right(layer)) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Codyna.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Codyna.scala new file mode 100644 index 00000000..af244af2 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Codyna.scala @@ -0,0 +1,26 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** The **fused** multi-layer-unfold → node-blind-fold refold ([[ReadScheme]]) — the mirror of + * [[Dyna]], opposite diagonal of the refold quadrant: a free-monad `futu` unfold ([[Coattr]]) + * whose fold is a plain `cata`, no intermediate `S`. Built by [[Futu.cross]] or [[Codyna.apply]]. + * Nominally distinct in the fused-refold family (see [[Hylo]]): honest `X = Nothing`. (`Codyna` is + * a descriptive name — the free-unfold/plain-fold refold has no standard one in the literature.) + */ +final class Codyna[A, B] private[zoo] (private val refold: A => B) extends ReadScheme[A, B]: + type X = Nothing + protected def read(a: A): B = refold(a) + +object Codyna: + + /** The fused free→plain refold `A => B`, `Traverse[F]` only. All-`Pure` `coalg` degenerates to + * [[Hylo]]. Stack-safe. + */ + def apply[F[_], A, B](coalg: A => F[Coattr[F, A]], alg: F[B] => B)(using + F: Traverse[F] + ): Codyna[A, B] = + val run = Machines.foldLayered[F, Coattr[F, A], B](Coattr.expand(coalg), (_, fr) => alg(fr)) + new Codyna[A, B](a => run(Coattr.Pure(a))) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coelgot.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coelgot.scala new file mode 100644 index 00000000..6edf5cfe --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coelgot.scala @@ -0,0 +1,25 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Co-Elgot citizen — a [[Hylo]] whose **fold may read the seed** ([[ReadScheme]]): + * `coalg: A => F[A]` unfolds, `alg: (A, F[B]) => B` folds with the originating seed in hand (the + * build-side analogue of [[Para]]'s subterm retention, on the fused refold). Built by + * [[Coelgot.apply]]. Nominally distinct in the fused-refold family (see [[Hylo]]): honest + * `X = Nothing`. + */ +final class Coelgot[A, B] private[zoo] (private val refold: A => B) extends ReadScheme[A, B]: + type X = Nothing + protected def read(a: A): B = refold(a) + +object Coelgot: + + /** The seed-reading refold `A => B`, `Traverse[F]` only. Ignoring the seed argument degenerates + * to [[Hylo]]. Stack-safe. + */ + def apply[F[_], A, B](coalg: A => F[A], alg: (A, F[B]) => B)(using + Traverse[F] + ): Coelgot[A, B] = + new Coelgot[A, B](Machines.foldLayered[F, A, B](coalg, (a, fr) => alg(a, fr))) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Comutu.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Comutu.scala new file mode 100644 index 00000000..23af2ca8 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Comutu.scala @@ -0,0 +1,31 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Comutumorphism citizen — the **build-side dual of [[Mutu]]** ([[BuildScheme]]) with **`X = + * Either[A, B]`**: two coalgebras unfold by mutual co-recursion, each slot tagged with the + * coalgebra that produced it. + * + * `coalgA: A => F[Either[A, B]]` and `coalgB: B => F[Either[A, B]]` are the two mutually + * co-recursive unfolds; the entry seed is an `A` (`Left`). It generalises [[Cozygo]] — that scheme + * is `comutu` where the secondary coalgebra never re-enters the primary type — and so, like its + * fold-side mirror, degenerates to [[Ana]] when only one coalgebra is ever reached. Stack-safe + * (the [[Machines.foldLayered]] machine). + */ +final class Comutu[F[_], A, B, S]( + private[zoo] val coalgA: A => F[Either[A, B]], + private[zoo] val coalgB: B => F[Either[A, B]], +)(using F: Traverse[F], E: Embed[F, S]) + extends BuildScheme[S, A]: + type X = Either[A, B] + + private val build: A => S = + val expand: Either[A, B] => F[Either[A, B]] = + case Left(a) => coalgA(a) + case Right(b) => coalgB(b) + val run = Machines.buildLayered[F, Either[A, B], S](expand) + a => run(Left(a)) + + protected def write(a: A): S = build(a) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cozygo.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cozygo.scala new file mode 100644 index 00000000..7c6a6f5b --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cozygo.scala @@ -0,0 +1,35 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Cozygomorphism citizen (the generalised apomorphism, *g-apo*) — the **build-side dual of + * [[Zygo]]** ([[BuildScheme]]) with **`X = Either[B, A]`**: each child slot is either `Left(b)` — + * a seed handed to the **auxiliary coalgebra** — or `Right(a)` — a seed for the main one. + * + * `aux: B => F[B]` is a self-contained unfold (a plain [[Ana]] over `B`); once a slot goes + * `Left(b)` it stays in `B`-land. `coalg: A => F[Either[B, A]]` is the main unfold, choosing per + * slot which coalgebra continues. It mirrors how [[Apo]] (`X = Either[S, A]`) sits above [[Ana]]: + * `cozygo`'s residual is `Either[B, A]` for an arbitrary auxiliary carrier `B` rather than the + * finished structure `S`. An all-`Right` `coalg` never consults `aux` and degenerates to [[Ana]]. + * + * '''Versus [[Apo]].''' Apo's `Left(s)` grafts an *already-built* `S` by reference (O(1), no + * recursion); cozygo's `Left(b)` keeps *unfolding* through `aux`, so it builds rather than grafts + * — the honest dual of zygo's auxiliary fold. Stack-safe (the [[Machines.foldLayered]] machine). + */ +final class Cozygo[F[_], A, B, S]( + private[zoo] val aux: B => F[B], + private[zoo] val coalg: A => F[Either[B, A]], +)(using F: Traverse[F], E: Embed[F, S]) + extends BuildScheme[S, A]: + type X = Either[B, A] + + private val build: A => S = + val expand: Either[B, A] => F[Either[B, A]] = + case Left(b) => F.map(aux(b))(Left(_)) + case Right(a) => coalg(a) + val run = Machines.buildLayered[F, Either[B, A], S](expand) + a => run(Right(a)) + + protected def write(a: A): S = build(a) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Dyna.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Dyna.scala new file mode 100644 index 00000000..6d64c84e --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Dyna.scala @@ -0,0 +1,26 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Dynamorphism citizen — the **fused** plain-unfold → course-of-value-fold refold + * ([[ReadScheme]]), the refold-quadrant diagonal between [[Hylo]] (plain→plain) and [[Chrono]] + * (free→cofree): a plain `ana` unfold whose fold sees each node's decorated history ([[Attr]]), no + * intermediate `S`. Built by [[Ana.cross]] or [[Dyna.apply]]. Nominally distinct in the + * fused-refold family (see [[Hylo]]): honest `X = Nothing`. + */ +final class Dyna[A, B] private[zoo] (private val refold: A => B) extends ReadScheme[A, B]: + type X = Nothing + protected def read(a: A): B = refold(a) + +object Dyna: + + /** The fused plain→cofree refold `A => B`, `Traverse[F]` only. Heads-only `alg` degenerates to + * [[Hylo]]. Stack-safe; retains O(n) `Attr` cells. + */ + def apply[F[_], A, B](coalg: A => F[A], alg: F[Attr[F, B]] => B)(using + F: Traverse[F] + ): Dyna[A, B] = + val build: A => Attr[F, B] = Machines.foldLayered[F, A, Attr[F, B]](coalg, Attr.decorate(alg)) + new Dyna[A, B](a => Attr.forget(build(a))) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Elgot.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Elgot.scala new file mode 100644 index 00000000..d6d3f6aa --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Elgot.scala @@ -0,0 +1,25 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Elgot citizen — a [[Hylo]] whose **unfold may short-circuit** ([[ReadScheme]]): `coalg: A => + * Either[B, F[A]]` answers `Left(b)` (the seed resolves directly, stop) or `Right(layer)` (keep + * unfolding); `alg: F[B] => B` folds the rest. Driven by the short-circuit-aware + * [[Machines.foldLayeredOr]]. Built by [[Elgot.apply]]. Nominally distinct in the fused-refold + * family (see [[Hylo]]): honest `X = Nothing`. + */ +final class Elgot[A, B] private[zoo] (private val refold: A => B) extends ReadScheme[A, B]: + type X = Nothing + protected def read(a: A): B = refold(a) + +object Elgot: + + /** The short-circuit refold `A => B`, `Traverse[F]` only. An all-`Right` `coalg` degenerates to + * [[Hylo]]. Stack-safe. + */ + def apply[F[_], A, B](coalg: A => Either[B, F[A]], alg: F[B] => B)(using + Traverse[F] + ): Elgot[A, B] = + new Elgot[A, B](Machines.foldLayeredOr[F, A, B](coalg, fr => alg(fr))) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala new file mode 100644 index 00000000..19a85bfd --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala @@ -0,0 +1,37 @@ +package dev.constructive.eo +package schemes +package zoo + +/** Monadic-fold citizen — the **effectful** read schemes ([[ReadScheme]] at focus `M[A]`): a fold + * whose algebra returns `M[A]` and whose effects are sequenced through the structure in `Foldable` + * order. Reads `S => M[A]` (`.get` yields `M[A]`). + * + * One class carries the whole read-side `M`-zoo — [[Schemes.cataM]] / [[Schemes.paraM]] / + * [[Schemes.histoM]] and the fused [[Schemes.hyloM]] / [[Schemes.chronoM]] — exactly as the pure + * [[Cata]] / [[Para]] / … differ only by the combine they hand the shared engine. The recursion + * index is **not** erased by the consolidation: it rides the phantom type parameter `XI`, so + * `cataM` is `FoldM[…, Nothing]`, `paraM` is `FoldM[…, F[(S, A)]]`, etc. — the same `X`-as-index + * thesis the pure zoo pins per class, here pinned per factory. + * + * All variants run on the single [[Machines.foldLayeredM]] engine (the `Monad[M]`-lifted walk, + * `tailRecM`-driven and stack-safe). '''Contract:''' `M` must be a **single-pass, linear, + * sequentially-evaluated** monad (`Id`, `Eval`, `State`, `IO`, …). A branching / replaying `M` + * (`List`, retrying effects) shares the engine's mutable walk state across branches and corrupts + * the fold — see [[Machines.foldLayeredM]]'s contract. + * + * @tparam XI + * the recursion index this fold retains — the optic existential `X`, carried as a type parameter + * so one class spans the whole read-side `M`-zoo without losing the index. + */ +final class FoldM[S, A, M[_], XI] private[zoo] (run: S => M[A]) extends ReadScheme[S, M[A]]: + type X = XI + protected def read(s: S): M[A] = run(s) + +object FoldM: + + /** Wrap an already-wired effectful fold `S => M[A]`. The engine plumbing lives in the [[Schemes]] + * `*M` factories (each picks the combine and pins `XI`); this just dresses the resulting + * function as the optic citizen. + */ + private[schemes] def apply[S, A, M[_], XI](run: S => M[A]): FoldM[S, A, M, XI] = + new FoldM[S, A, M, XI](run) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala new file mode 100644 index 00000000..b74b55c3 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala @@ -0,0 +1,36 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Futumorphism citizen — a multi-layer unfold ([[BuildScheme]]) with **`X = Coattr[F, A]`**, the + * free monad `μX. A + F[X]`. The build-side mirror of [[Histo]]. `coalg: A => F[Coattr[F, A]]` + * answers each slot with [[Coattr.Pure]] (keep unfolding) or [[Coattr.Roll]] (a prebuilt layer, no + * coalgebra call), so one step may emit several layers; the root seed enters as `Coattr.Pure`. An + * all-`Pure` coalgebra degenerates to [[Ana]]. Carries `coalg` so [[cross]] can fuse with + * [[Histo]] (→ [[Chrono]]) or [[Cata]] (→ [[Codyna]]). Stack-safe. + */ +final class Futu[F[_], A, S](private[zoo] val coalg: A => F[Coattr[F, A]])(using + F: Traverse[F], + E: Embed[F, S], +) extends BuildScheme[S, A]: + type X = Coattr[F, A] + + private[zoo] val build: A => S = + val run = Machines.buildLayered[F, Coattr[F, A], S](Coattr.expand(coalg)) + a => run(Coattr.Pure(a)) + + protected def write(a: A): S = build(a) + + /** The fused **chrono** seam: futu ∘ [[Histo]] — [[Hylo]] at the universal indices. The build + * threads the free monad ([[Coattr]]), the fold the cofree comonad ([[Attr]]); fused, no + * intermediate `S`. + */ + def cross[B](histo: Histo[F, S, B]): Chrono[A, B] = Chrono[F, A, B](coalg, histo.alg) + + /** The fused mirror-of-dyna seam: futu ∘ node-blind [[Cata]] (→ [[Codyna]]) — opposite diagonal + * of the refold quadrant. Fuses; the [[Coattr]] free layers are threaded internally, no + * intermediate `S`. + */ + def cross[B](cata: Cata[F, S, B]): Codyna[A, B] = Codyna[F, A, B](coalg, cata.alg) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala new file mode 100644 index 00000000..32ed90f5 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala @@ -0,0 +1,34 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Histomorphism citizen — a course-of-value fold ([[ReadScheme]]) with **`X = Attr[F, A]`**, the + * cofree comonad `νX. A × F[X]`. The thesis at its sharpest: the histomorphism's existential is + * *literally* the universal index for folds. `Cata` is the same shape at `X = Nothing`; `Histo` + * keeps the whole decorated history, so `Histo : Cata :: Lens : Getter`. + * + * `alg: F[Attr[F, A]] => A` sees, per child, not just its folded result but its entire decorated + * subtree ([[Attr.head]] = result, [[Attr.tail]] = the child's own decorated layer) — folds + * unreachable by a single-pass [[Cata]]. Heads-only (`alg ∘ map(_.head)`) degenerates to [[Cata]]. + * Space honesty: course-of-value recursion retains O(n) `Attr` cells by nature. Stack-safe. + */ +final class Histo[F[_], S, A](private[zoo] val alg: F[Attr[F, A]] => A)(using + F: Traverse[F], + P: Project[F, S], +) extends ReadScheme[S, A]: + type X = Attr[F, A] + + private val toAttr: S => Attr[F, A] = + Machines.foldLayered[F, S, Attr[F, A]](P.project, Attr.decorate(alg)) + + private val run: S => A = s => Attr.forget(toAttr(s)) + protected def read(s: S): A = run(s) + + /** Metamorphism at the universal indices — the **fold→unfold dual of [[Futu.cross]]**'s chrono. + * Fold `this` course-of-value to the neck `A`, then multi-layer-unfold it with `futu` into a + * fresh `G`-recursive `T`. **Does not fuse** — `F`/`G` differ, so the neck is materialised. + */ + def meta[G[_], T](futu: Futu[G, A, T]): MetaChrono[S, A, T] = + new MetaChrono[S, A, T](run.andThen(futu.build)) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Hylo.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Hylo.scala new file mode 100644 index 00000000..d9a8804c --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Hylo.scala @@ -0,0 +1,30 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Hylomorphism citizen — the **fused** refold ([[ReadScheme]]) with `X = Nothing`. Built by + * [[Ana.cross]] or [[Hylo.apply]]; carries only the fused `refold`, no tree. Not a primitive — it + * *is* `ana.cross(cata)`. + * + * The fused-refold family ([[Hylo]] / [[Chrono]] / [[Dyna]] / [[Codyna]] / [[Elgot]] / + * [[Coelgot]]) share this shape — a `refold: Seed => A` with a vestigial build side, so + * `X = Nothing` for all of them honestly. They are **nominally distinct** named types (one per + * construction), not different existential indices: fusion is exactly the act of discarding the + * intermediate index. + */ +final class Hylo[Seed, A] private[zoo] (private val refold: Seed => A) extends ReadScheme[Seed, A]: + type X = Nothing + protected def read(s: Seed): A = refold(s) + +object Hylo: + + /** The fused refold `Seed => A`, building **no intermediate `S`** (needs only `Traverse[F]`). + * `coalg` unfolds a seed into one typed layer; `alg` folds the layer's results (node-blind, like + * [[Cata]]). Definitionally `ana(coalg).cross(cata(alg))`. Stack-safe. + */ + def apply[F[_], Seed, A](coalg: Seed => F[Seed], alg: F[A] => A)(using + F: Traverse[F] + ): Hylo[Seed, A] = + new Hylo[Seed, A](Machines.foldLayered[F, Seed, A](coalg, (_, fr) => alg(fr))) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala new file mode 100644 index 00000000..94c98841 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala @@ -0,0 +1,39 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Metamorphism citizen — a **fold-then-unfold** read `S => T` ([[ReadScheme]]). The fold-direction + * dual of [[Hylo]]: where `hylo` is the fused unfold-then-fold (`X = Nothing`, deforests), `meta` + * is the fold-then-unfold whose existential **`X = A` is the neck** — the intermediate value the + * fold produces and the unfold consumes. + * + * That non-trivial `X` is the honest statement that `meta` **cannot fuse**: it folds a functor `F` + * down to `A`, then unfolds a *different* `G` back up; with `F ≠ G` there is no `project ∘ embed` + * cancellation, so `A` is genuinely materialised. (Even `F = G` does not fuse it — the barrier is + * the scalar neck, not the functor mismatch.) Built by [[Cata.meta]] or [[Meta.apply]]. + * + * @tparam A + * the neck — the retained intermediate value type (the optic's existential `X`) + */ +final class Meta[S, A, T] private[zoo] (private val run: S => T) extends ReadScheme[S, T]: + type X = A + protected def read(s: S): T = run(s) + +object Meta: + + /** The fold-then-unfold read `S => T`: fold the `F`-recursive `S` to a neck `A` (node-blind + * `alg`), then unfold `A` into a fresh `G`-recursive `T` (`coalg`). **Does not fuse** — it keeps + * *both* `Basis`es (`Project[F, S]` to fold, `Embed[G, T]` to build); where `hylo` needs only + * `Traverse`, `meta` cannot drop either. Stack-safe (two [[Machines.foldLayered]] passes). + */ + def apply[F[_], S, A, G[_], T](alg: F[A] => A, coalg: A => G[A])(using + F: Traverse[F], + P: Project[F, S], + G: Traverse[G], + E: Embed[G, T], + ): Meta[S, A, T] = + val fold: S => A = Machines.foldLayered[F, S, A](P.project, (_, fr) => alg(fr)) + val unfold: A => T = Machines.buildLayered[G, A, T](coalg) + new Meta[S, A, T](fold.andThen(unfold)) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/MetaChrono.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/MetaChrono.scala new file mode 100644 index 00000000..c933bdc2 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/MetaChrono.scala @@ -0,0 +1,38 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Metamorphism at the universal indices ([[ReadScheme]]) — the **fold→unfold dual of [[Chrono]]**, + * reading `S => T`. Fold the `F`-recursive `S` course-of-value to a neck `A` (the cofree history, + * [[Attr]]), then multi-layer-unfold `A` into a `G`-recursive `T` (the free coalgebra, + * [[Coattr]]). Built by [[Histo.meta]] or [[MetaChrono.apply]]. + * + * The universal-index twin of [[Meta]]: same `X = A` neck, same no-fusion (`F ≠ G`). The cofree + * comonad on the fold side and the free monad on the unfold side never cancel across the neck — + * `chrono` is exactly this combination *with `F = G`*, where they do. + * + * @tparam A + * the neck — the retained intermediate value type (the optic's existential `X`) + */ +final class MetaChrono[S, A, T] private[zoo] (private val run: S => T) extends ReadScheme[S, T]: + type X = A + protected def read(s: S): T = run(s) + +object MetaChrono: + + /** The course-of-value fold → multi-layer unfold read `S => T`. **Does not fuse** — keeps both + * `Basis`es (`Project[F, S]`, `Embed[G, T]`). Stack-safe (two passes). + */ + def apply[F[_], S, A, G[_], T]( + algebra: F[Attr[F, A]] => A, + coalg: A => G[Coattr[G, A]], + )(using F: Traverse[F], P: Project[F, S], G: Traverse[G], E: Embed[G, T]): MetaChrono[S, A, T] = + val fold: S => A = + val toAttr = Machines.foldLayered[F, S, Attr[F, A]](P.project, Attr.decorate(algebra)) + s => Attr.forget(toAttr(s)) + val unfold: A => T = + val run = Machines.buildLayered[G, Coattr[G, A], T](Coattr.expand(coalg)) + a => run(Coattr.Pure(a)) + new MetaChrono[S, A, T](fold.andThen(unfold)) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Mutu.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Mutu.scala new file mode 100644 index 00000000..f847219f --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Mutu.scala @@ -0,0 +1,28 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Mutumorphism citizen — a fold by **mutual recursion** ([[ReadScheme]]) with **`X = F[(A, B)]`**: + * two algebras compute a pair `(A, B)` per node, each free to read both halves of its children. + * + * `algA: F[(A, B)] => A` and `algB: F[(A, B)] => B` are the two mutually-recursive functions; the + * citizen returns the `A` half. It generalises [[Zygo]] — `zygo(aux)(alg)` is `mutu` where the + * second algebra ignores the `A` half (`algB = aux ∘ map(_._2)`) — and so, transitively, [[Para]] + * and [[Cata]]. The two results are computed in **one pass** over the structure. Stack-safe (the + * [[Machines.foldLayered]] machine). + */ +final class Mutu[F[_], S, A, B]( + private[zoo] val algA: F[(A, B)] => A, + private[zoo] val algB: F[(A, B)] => B, +)(using F: Traverse[F], P: Project[F, S]) + extends ReadScheme[S, A]: + type X = F[(A, B)] + + private val run: S => A = + val fold: S => (A, B) = + Machines.foldLayered[F, S, (A, B)](P.project, (_, fab) => (algA(fab), algB(fab))) + s => fold(s)._1 + + protected def read(s: S): A = run(s) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala new file mode 100644 index 00000000..94f5f10a --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala @@ -0,0 +1,34 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Paramorphism citizen — a fold that **retains the original subterms** ([[ReadScheme]]) with **`X = + * F[(S, A)]`**: each child slot pairs the original subterm `S` with its folded result `A`. + * + * `alg: F[(S, A)] => A` is strictly more informed than [[Cata]]'s `F[A] => A` — it can read the + * subterm itself, not just its summary. Ignoring the `S` half degenerates to [[Cata]]. + * + * '''On the existential, honestly.''' `X = F[(S, A)]` is the store-comonad complement, which is + * why the brainstorm flags `para` as the candidate *writable* scheme (`para : Cata :: Lens : + * Getter`). get-put holds definitionally (re-embedding the retained subterms rebuilds the node), + * but put-get holds only under an algebra-coherence condition — so the lawful writable `Lens` is + * conditional, not free. This citizen ships the unconditionally-sound read; the writable put is a + * scoped follow-up rather than an asserted capability. + * + * Subterms come from the layer the machine already expanded — each child is paired with its folded + * result positionally, in `Foldable` order (sound for any lawful `Traverse`), so there is no + * per-node re-`project` and no per-node `List`. Stack-safe (the [[Machines.foldLayeredPaired]] + * machine). + */ +final class Para[F[_], S, A](private[zoo] val alg: F[(S, A)] => A)(using + F: Traverse[F], + P: Project[F, S], +) extends ReadScheme[S, A]: + type X = F[(S, A)] + + private val run: S => A = + Machines.foldLayeredPaired[F, S, A](P.project, (_, paired) => alg(paired)) + + protected def read(s: S): A = run(s) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Postpro.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Postpro.scala new file mode 100644 index 00000000..46ca5a31 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Postpro.scala @@ -0,0 +1,34 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.{~>, Traverse} + +/** Postpromorphism citizen — an unfold that applies a **natural transformation `η : F ~> F` after + * each step** ([[BuildScheme]]). The build-side mirror of [[Prepro]]: the coalgebra + * `coalg: A => F[A]` is exactly [[Ana]]'s (so `X = S`, the structure it threads), but each emitted + * subtree is hoisted through `η` once per level it sits below the root. + * + * Like [[Prepro]], this is the layer-transforming axis, not an index refinement: `apo`/`futu` + * refine the residual `X`; `postpro` keeps `ana`'s index and decorates the *layer optic* on the + * `embed` glue side. With `η = id` it is exactly [[Ana]]. + * + * '''Cost, honestly.''' Each built child subtree is hoisted (`embed ∘ η` at every layer) before + * its parent embeds it, so a node at depth `k` is re-transformed `k` times: `O(n · depth)`, the + * inherent cost of the postpromorphism. Both the outer build and each hoist run on the stack-safe + * [[Machines.foldLayered]] machine. + */ +final class Postpro[F[_], A, S]( + private[zoo] val eta: F ~> F, + private[zoo] val coalg: A => F[A], +)(using F: Traverse[F], P: Project[F, S], E: Embed[F, S]) + extends BuildScheme[S, A]: + type X = S + + private val build: A => S = + // The one-shot hoist: apply η at *every* layer of a built S, rebuilding it. + val hoist: S => S = Machines.foldLayered[F, S, S](P.project, (_, fr) => E.embed(eta(fr))) + // Embed each step, hoisting every child subtree first, so depth-k nodes accumulate η k times. + Machines.foldLayered[F, A, S](coalg, (_, fr) => E.embed(F.map(fr)(hoist))) + + protected def write(a: A): S = build(a) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Prepro.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Prepro.scala new file mode 100644 index 00000000..46a7915c --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Prepro.scala @@ -0,0 +1,36 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.{~>, Traverse} + +/** Prepromorphism citizen — a fold that applies a **natural transformation `η : F ~> F` before + * recursing** ([[ReadScheme]]). The algebra `alg: F[A] => A` is exactly [[Cata]]'s — node-blind, + * so `X = Nothing` — but the recursion is reshaped: the layer reaching a node at depth `k` has had + * `η` applied `k` times. + * + * This is the orthogonal axis to the comonad/monad index towers. `para`/`histo` refine *what the + * algebra sees* (the existential `X`); `prepro` keeps the trivial index and instead decorates the + * *layer optic* — the `project` peel is pre-composed with the accumulating `η`. With `η = id` it + * is exactly [[Cata]]. + * + * '''Cost, honestly.''' Each descent applies `η` to a whole subtree before folding it (the + * one-shot hoist `embed ∘ η` at every layer), so a node at depth `k` is re-transformed `k` times: + * `O(n · depth)` total, the inherent cost of the prepromorphism (Uustalu & Vene). Both the outer + * fold and each hoist run on the stack-safe [[Machines.foldLayered]] machine. + */ +final class Prepro[F[_], S, A]( + private[zoo] val eta: F ~> F, + private[zoo] val alg: F[A] => A, +)(using F: Traverse[F], P: Project[F, S], E: Embed[F, S]) + extends ReadScheme[S, A]: + type X = Nothing + + private val run: S => A = + // The one-shot hoist: apply η at *every* layer of an S, rebuilding it. + val hoist: S => S = Machines.foldLayered[F, S, S](P.project, (_, fr) => E.embed(eta(fr))) + // Descend pre-hoisting each child, so depth-k nodes accumulate η k times. + val expand: S => F[S] = s => F.map(P.project(s))(hoist) + Machines.foldLayered[F, S, A](expand, (_, fr) => alg(fr)) + + protected def read(s: S): A = run(s) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/SchemeShapes.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/SchemeShapes.scala new file mode 100644 index 00000000..c72f0def --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/SchemeShapes.scala @@ -0,0 +1,32 @@ +package dev.constructive.eo +package schemes +package zoo + +import data.Direct +import optics.Optic + +/** The two carrier-wearing shapes every scheme citizen takes, factored so the + * [[dev.constructive.eo.data.Direct]] wrapping lives in **one** place instead of being respelled + * in every citizen — a carrier change touches these two classes, not all fourteen (the cost the + * `Scheme`→`Direct` migration paid by hand). + * + * The `read`/`write` member is virtual (one dispatch per fold), which is immaterial here: a + * scheme's `.get`/`.reverseGet` is called once per O(n) fold, so the indirection core + * `Getter`/`Review` avoid for *hot composed* reads (their ~1.8× megamorphic-dispatch finding) does + * not apply. Each subclass supplies the function and pins its existential `type X` (the recursion + * index). + */ + +/** Read-direction scheme — a `Getter`-shaped optic over `Direct` reading `S => A` (`.get`). */ +abstract class ReadScheme[S, A] extends Optic[S, Unit, A, Unit, Direct]: + protected def read(s: S): A + final def to(s: S): Direct[X, A] = Direct[X, A](read(s)) + final def from(b: Direct[X, Unit]): Unit = () + +/** Build-direction scheme — a `Review`-shaped optic over `Direct` building `B => T` + * (`.reverseGet`). + */ +abstract class BuildScheme[T, B] extends Optic[Unit, T, Unit, B, Direct]: + protected def write(b: B): T + final def to(u: Unit): Direct[X, Unit] = Direct[X, Unit](()) + final def from(d: Direct[X, B]): T = write(Direct.value(d)) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Zygo.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Zygo.scala new file mode 100644 index 00000000..b4145d0c --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Zygo.scala @@ -0,0 +1,39 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +/** Zygomorphism citizen — a fold carrying an **auxiliary algebra** alongside the main one + * ([[ReadScheme]]) with **`X = F[(B, A)]`**: each child slot pairs the auxiliary result `B` with + * the main result `A`. + * + * `aux: F[B] => B` runs a second, self-contained fold whose results the main `alg: F[(B, A)] => A` + * may read per child. It is the rung the comonad tower skips between [[Cata]] (`X = Nothing`) and + * [[Para]] (`X = F[(S, A)]`): `para` is exactly `zygo` at `B = S` with `aux = embed` (the + * auxiliary fold rebuilds the original subterm), and ignoring the `B` half (`alg ∘ map(_._2)`) + * degenerates to [[Cata]]. The further generalisation — letting `aux` also see the `A` half — is + * the mutumorphism ([[Mutu]]). + * + * '''On the existential.''' `X = F[(B, A)]` is the store comonad over the auxiliary carrier `B`, + * the same store-comonad complement [[Para]] flags as its writable candidate, but over an + * arbitrary `B` rather than the structure `S`. The two results are computed in **one pass** (the + * fold yields `(B, A)` pairs; the final projection keeps the `A`). Stack-safe (the + * [[Machines.foldLayered]] machine). + */ +final class Zygo[F[_], S, A, B]( + private[zoo] val aux: F[B] => B, + private[zoo] val alg: F[(B, A)] => A, +)(using F: Traverse[F], P: Project[F, S]) + extends ReadScheme[S, A]: + type X = F[(B, A)] + + private val run: S => A = + val fold: S => (B, A) = + Machines.foldLayered[F, S, (B, A)]( + P.project, + (_, fba) => (aux(F.map(fba)(_._1)), alg(fba)), + ) + s => fold(s)._2 + + protected def read(s: S): A = run(s) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala new file mode 100644 index 00000000..090d6e14 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala @@ -0,0 +1,58 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import org.specs2.mutable.Specification + +import data.Affine +import optics.Optic +import optics.Optic.* // reverseGet +import schemes.samples.{Bin, BinF} + +/** apo re-carriered onto [[data.Affine]]: its per-slot residual is now [[Schemes.apoScatter]], a + * composable `Affine`-carried scatter optic (`Left → Miss`, the O(1) graft; `Right → Hit`, keep + * unfolding), and apo's engine constructs + consumes that decision through it. Pins the scatter's + * `Miss`/`Hit` semantics, that it composes via `Affine.assoc`, and that the scheme still builds + * (graft intact) after the re-carriering. + */ +class ApoScatterSpec extends Specification: + + private val sc = Schemes.apoScatter[Bin, Int] + + "apoScatter maps Left → Miss (carrying the grafted subtree)" >> { + sc.to(Left(Bin.Leaf(7))).fold(s => s, (_, _) => Bin.Leaf(-1)) === Bin.Leaf(7) + } + + "apoScatter maps Right → Hit (carrying the keep-going focus)" >> { + sc.to(Right(9)).fold(_ => -1, (_, b) => b) === 9 + } + + // A second Affine optic on the focus Int — Miss on negatives — to compose under apoScatter. + private val innerToy: Optic[Int, Unit, Int, Unit, Affine] { type X = (Int, Unit) } = + new Optic[Int, Unit, Int, Unit, Affine]: + type X = (Int, Unit) + def to(n: Int): Affine[X, Int] = + if n < 0 then new Affine.Miss[X](n) else new Affine.Hit[X, Int]((), n) + def from(b: Affine[X, Unit]): Unit = () + + private val composed = sc.andThen(innerToy) + + "apoScatter composes via Affine.assoc — Hit∘Hit threads the focus" >> { + composed.to(Right(5)).fold(_ => -1, (_, b) => b) === 5 + } + + "apoScatter composes — outer Miss (graft) short-circuits the composition" >> { + composed.to(Left(Bin.Leaf(0))).fold(_ => -1, (_, b) => b) === -1 + } + + "apoScatter composes — inner Miss short-circuits the keep-going arm" >> { + composed.to(Right(-3)).fold(_ => -1, (_, b) => b) === -1 + } + + "the re-carriered apo still builds: Miss grafts, Hit unfolds" >> { + val coalg: Int => BinF[Either[Bin, Int]] = + n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Left(Bin.Leaf(99)), Right(n - 1)) + Schemes.apo[BinF, Int, Bin](coalg).reverseGet(2) === + Bin.Branch(Bin.Leaf(99), Bin.Branch(Bin.Leaf(99), Bin.Leaf(0))) + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala new file mode 100644 index 00000000..5d169d5b --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala @@ -0,0 +1,116 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import org.specs2.mutable.Specification + +import optics.Optic.* // get, reverseGet +import schemes.samples.{Bin, BinF} +import schemes.zoo.{Attr, Coattr} + +/** The chronomorphism, and its **fuse efficiency**: `chrono` is `hylo` at the universal indices — + * `futu.cross(histo)` (build through the free monad `Coattr`, fold through the cofree comonad + * `Attr`) — and like `hylo` it fuses, building **no intermediate `S`**. + * + * - chrono law: the fused `futu.cross(histo)` equals the materialising `histo.get ∘ + * futu.reverseGet`, and equals [[Schemes.chrono]]. + * - fuse efficiency: an instrumented [[Basis]] witnesses that the fused refold calls + * `project`/`embed` **zero** times (whereas the materialising spelling calls each once per + * node), and the fused refold is stack-safe at 10⁶ — no `S` to overflow. + * - degeneration: all-`Pure` coalg + heads-only algebra collapses chrono to [[Schemes.hylo]]. + */ +class ChronoSpec extends Specification: + + sequential + + // Counts layer peels (`project`) and glues (`embed`). The fused chrono touches neither. + final private class CountingBasis extends Basis[BinF, Bin]: + var projects = 0 + var embeds = 0 + + def project(s: Bin): BinF[Bin] = + projects += 1 + s match + case Bin.Leaf(n) => BinF.LeafF(n) + case Bin.Branch(l, r) => BinF.BranchF(l, r) + + def embed(fs: BinF[Bin]): Bin = + embeds += 1 + fs match + case BinF.LeafF(n) => Bin.Leaf(n) + case BinF.BranchF(l, r) => Bin.Branch(l, r) + + // futu coalgebra (all-Pure here): a balanced split down to unit leaves. + private val coalg: Int => BinF[Coattr[BinF, Int]] = n => + if n <= 1 then BinF.LeafF(1) + else BinF.BranchF(Coattr.Pure(n / 2), Coattr.Pure(n - n / 2)) + + // histo algebra (heads-only here): leaf sum. Reads each child's Attr head. + private val sumAlg: BinF[Attr[BinF, Int]] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l.head + r.head + + "futu.cross(histo) == histo.get ∘ futu.reverseGet == Schemes.chrono (the chrono law)" >> { + val seeds = List(1, 2, 3, 5, 8, 13, 21) + val basis = new CountingBasis + val futu = Schemes.futu[BinF, Int, Bin](coalg)(using BinF.traverse, basis) + val histo = Schemes.histo[BinF, Bin, Int](sumAlg)(using BinF.traverse, basis) + + val fused = futu.cross(histo) // Hylo[Int, Int] + val direct = Schemes.chrono[BinF, Int, Int](coalg, sumAlg) + + val fusedR = seeds.map(fused.get) + val materialisedR = seeds.map(s => histo.get(futu.reverseGet(s))) + val directR = seeds.map(direct.get) + + (fusedR === materialisedR).and(fusedR === directR).and(fusedR === seeds) // sum of unit leaves + } + + "fuse efficiency: the fused chrono builds NO intermediate Bin (project/embed never called)" >> { + val basis = new CountingBasis + val futu = Schemes.futu[BinF, Int, Bin](coalg)(using BinF.traverse, basis) + val histo = Schemes.histo[BinF, Bin, Int](sumAlg)(using BinF.traverse, basis) + + val _ = futu.cross(histo).get(21) // run the whole refold + + // Deforestation witness: the fused pass threads Coattr/Attr only — zero peels, zero glues. + (basis.projects === 0).and(basis.embeds === 0) + } + + "contrast: the materialising spelling DOES build the Bin (embed-per-node, then project-per-node)" >> { + val basis = new CountingBasis + val futu = Schemes.futu[BinF, Int, Bin](coalg)(using BinF.traverse, basis) + val histo = Schemes.histo[BinF, Bin, Int](sumAlg)(using BinF.traverse, basis) + + val built = futu.reverseGet(21) // builds the tree: one embed per node + val nodes = basis.embeds + val _ = histo.get(built) // folds the tree: one project per node + + (basis.projects === nodes).and(nodes > 0) + } + + "chrono degenerates to hylo (all-Pure coalg + heads-only algebra)" >> { + val seeds = List(1, 2, 3, 5, 8, 13, 21) + // plain hylo over the same shape: coalg' : Int => BinF[Int], alg' : BinF[Int] => Int + val plainCoalg: Int => BinF[Int] = + n => if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + val plainAlg: BinF[Int] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + val viaChrono = Schemes.chrono[BinF, Int, Int](coalg, sumAlg) + val viaHylo = Schemes.hylo[BinF, Int, Int](plainCoalg, plainAlg) + seeds.map(viaChrono.get) === seeds.map(viaHylo.get) + } + + "the fused chrono builds no intermediate Bin and is stack-safe at depth 10^6" >> { + val Deep = 1_000_000 + // all-Pure deep spine; -1 terminates a branch with a leaf. + val spineCoalg: Int => BinF[Coattr[BinF, Int]] = n => + if n <= 0 then BinF.LeafF(0) + else BinF.BranchF(Coattr.Pure(n - 1), Coattr.Pure(-1)) + val depthAlg: BinF[Attr[BinF, Int]] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l.head, r.head) + (Schemes.chrono[BinF, Int, Int](spineCoalg, depthAlg).get(Deep) == Deep) must beTrue + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala new file mode 100644 index 00000000..faab993e --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala @@ -0,0 +1,107 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import org.specs2.mutable.Specification + +import optics.Optic.* // get, reverseGet +import schemes.samples.{Bin, BinF} + +/** The thesis, as an executable proof: **hylo is the fusion of ana and cata**, automatic from the + * existential. + * + * `ana` is a build (`Review`-shaped, `X = S`) and `cata` a node-blind fold (`Getter`-shaped, `X = + * Nothing`); the build⇄read seam between them is `ana.cross(cata)` (definitionally + * `ana.reverse.andThen(cata)`). Because the citizens keep their `coalg`/`alg` alive, that compose + * **fuses**: + * + * - it equals the materialising `cata.get ∘ ana.reverseGet` (the hylo law), and + * - it equals [[Schemes.hylo]], and + * - it builds **no intermediate `S`** — made observable below by an instrumented [[Basis]] whose + * `project`/`embed` the fused refold never calls (whereas the materialising spelling calls + * each once per node). + */ +class FusionSpec extends Specification: + + sequential + + // A Basis that counts how many layers it peels (`project`) and glues (`embed`). The fused refold + // touches neither — it threads `coalg`/`alg` directly — so the counters are the deforestation + // witness. + final private class CountingBasis extends Basis[BinF, Bin]: + var projects = 0 + var embeds = 0 + + def project(s: Bin): BinF[Bin] = + projects += 1 + s match + case Bin.Leaf(n) => BinF.LeafF(n) + case Bin.Branch(l, r) => BinF.BranchF(l, r) + + def embed(fs: BinF[Bin]): Bin = + embeds += 1 + fs match + case BinF.LeafF(n) => Bin.Leaf(n) + case BinF.BranchF(l, r) => Bin.Branch(l, r) + + // seed n: a balanced split down to leaves of weight 1. + private val coalg: Int => BinF[Int] = + n => if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + + private val sumLeaves: BinF[Int] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + "ana.cross(cata) == cata.get ∘ ana.reverseGet == hylo (the hylo law)" >> { + val seeds = List(1, 2, 3, 5, 8, 13, 21) + val basis = new CountingBasis + val ana = Schemes.ana[BinF, Int, Bin](coalg)(using BinF.traverse, basis) + val cata = Schemes.cata[BinF, Bin, Int](sumLeaves)(using BinF.traverse, basis) + + val fused = ana.cross(cata) // Hylo[Int, Int] + val direct = Schemes.hylo[BinF, Int, Int](coalg, sumLeaves) + + val fusedR = seeds.map(fused.get) + val materialisedR = seeds.map(s => cata.get(ana.reverseGet(s))) + val directR = seeds.map(direct.get) + + (fusedR === materialisedR).and(fusedR === directR).and(fusedR === seeds) // sum of unit leaves + } + + "the fused refold builds NO intermediate Bin: project/embed are never called" >> { + val basis = new CountingBasis + val ana = Schemes.ana[BinF, Int, Bin](coalg)(using BinF.traverse, basis) + val cata = Schemes.cata[BinF, Bin, Int](sumLeaves)(using BinF.traverse, basis) + + val fused = ana.cross(cata) + val _ = fused.get(21) // run the whole refold + + // Deforestation witness: the fused pass threads coalg/alg only — zero layer peels, zero glues. + (basis.projects === 0).and(basis.embeds === 0) + } + + "the materialising spelling DOES build the Bin: embed-per-node on build, project-per-node on fold" >> { + val basis = new CountingBasis + val ana = Schemes.ana[BinF, Int, Bin](coalg)(using BinF.traverse, basis) + val cata = Schemes.cata[BinF, Bin, Int](sumLeaves)(using BinF.traverse, basis) + + val built = ana.reverseGet(21) // builds the tree: one embed per node + val nodes = basis.embeds + val _ = cata.get(built) // folds the tree: one project per node + + // Same node count on both passes, and it is non-trivial (the tree was really built). + (basis.projects === nodes).and(nodes > 0) + } + + "the fused hylo builds no intermediate Bin and is stack-safe at depth 10^6" >> { + val Deep = 1_000_000 + val spineCoalg: Int => BinF[Int] = + n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) + val depthAlg: BinF[Int] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + val fused = + Schemes.ana[BinF, Int, Bin](spineCoalg).cross(Schemes.cata[BinF, Bin, Int](depthAlg)) + (fused.get(Deep) == Deep) must beTrue + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala new file mode 100644 index 00000000..3b921626 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala @@ -0,0 +1,171 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import org.specs2.mutable.Specification + +import optics.Optic.* // get, reverseGet +import schemes.samples.{Bin, BinF, Rose, RoseF} +import schemes.zoo.{Attr, Coattr} + +/** The metamorphism — fold→unfold, the direction-dual of [[FusionSpec]]'s hylo — and the proof that + * it is the **honest non-fusion**. + * + * `meta` folds an `F`-recursive `S` to a neck value `A`, then unfolds a *different* `G`-recursive + * `T`. Here `F = BinF`, `G = RoseF`, `T = Rose` — genuinely different functors, which is *why* it + * cannot deforest: there is no shared functor whose `project ∘ embed` could cancel, so the neck + * `A` is materialised (the `Meta` existential is `X = A`). + * + * - meta == `ana.reverseGet ∘ cata.get` (it *is* the two-pass composition); + * - both passes run — the `BinF` fold calls `project`, the `RoseF` build calls `embed` (contrast + * [[FusionSpec]]'s fused hylo, which calls *neither*); + * - `metaChrono` is the same at the universal indices (histo→futu), degenerating to `meta`; + * - stack-safe to 10⁶ across both passes. + */ +class MetaSpec extends Specification: + + sequential + + // Counting bases, split by side: meta needs Project[BinF, Bin] to fold and Embed[RoseF, Rose] to + // build — so a non-zero `projects` proves the fold pass ran, a non-zero `embeds` the build pass. + final private class CountingBin extends Basis[BinF, Bin]: + var projects = 0 + + def project(s: Bin): BinF[Bin] = + projects += 1 + s match + case Bin.Leaf(n) => BinF.LeafF(n) + case Bin.Branch(l, r) => BinF.BranchF(l, r) + + def embed(fs: BinF[Bin]): Bin = BinF.basis.embed(fs) + + final private class CountingRose extends Basis[RoseF, Rose]: + var embeds = 0 + def project(r: Rose): RoseF[Rose] = RoseF.basis.project(r) + + def embed(fr: RoseF[Rose]): Rose = + embeds += 1 + RoseF.basis.embed(fr) + + // mixed tree: Branch(Leaf 1, Branch(Leaf 2, Leaf 3)) — leaf sum 6. + private val tree: Bin = Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) + + // fold (F = BinF): leaf sum → the neck Int. + private val leafSum: BinF[Int] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + // unfold (G = RoseF): neck n → a left Rose spine of n+1 nodes (labels n, n-1, …, 0). + private val spine: Int => RoseF[Int] = n => + if n <= 0 then RoseF(0, Nil) else RoseF(n, List(n - 1)) + + // count Rose nodes, to observe the built T. + private val countRose: RoseF[Int] => Int = fr => 1 + fr.kids.sum + + "meta folds F then unfolds a different G: Bin --leafSum--> Int --spine--> Rose" >> { + val m = Schemes.meta[BinF, Bin, Int, RoseF, Rose](leafSum, spine) + val built: Rose = m.get(tree) // neck 6 → spine of 7 nodes + Schemes.cata[RoseF, Rose, Int](countRose).get(built) === 7 + } + + "meta == ana.reverseGet ∘ cata.get via the cata.meta(ana) seam (it IS the two-pass composition)" >> { + val cata = Schemes.cata[BinF, Bin, Int](leafSum) + val ana = Schemes.ana[RoseF, Int, Rose](spine) + val viaSeam = cata.meta(ana) + val viaManual: Bin => Rose = s => ana.reverseGet(cata.get(s)) + val trees = List(tree, Bin.Leaf(4), Bin.Branch(Bin.Leaf(5), Bin.Leaf(6))) + trees.map(viaSeam.get) === trees.map(viaManual) + } + + "no fusion: meta materialises the neck — BOTH the F-fold's project AND the G-build's embed run" >> { + val cb = new CountingBin + val cr = new CountingRose + val m = Schemes.meta[BinF, Bin, Int, RoseF, Rose](leafSum, spine)(using + BinF.traverse, + cb, + RoseF.traverse, + cr, + ) + val _ = m.get(tree) // neck 6 → 7 Rose nodes + + // Contrast FusionSpec's hylo (both zero). Here neither is zero — F ≠ G, the neck is real. + (cb.projects must be_>(0)).and(cr.embeds must be_>(0)) + } + + // Probe the F =:= G hypothesis: if the type mismatch were the barrier, a SAME-functor meta should + // fuse (drop project/embed to 0). It doesn't — one Basis[BinF, Bin] serves both sides, the + // obstruction is gone, yet BOTH counters still fire. The real barrier is the scalar neck: the + // fold's projects (input side) are never adjacent to the unfold's embeds (output side), so there + // is no `project ∘ embed` to cancel. F = G removes a *sufficient* witness for no-fusion, not the + // *cause*. + "F = G = BinF STILL does not fuse: the scalar neck, not the functor mismatch, is the barrier" >> { + final class Counting extends Basis[BinF, Bin]: + var projects = 0 + var embeds = 0 + def project(s: Bin): BinF[Bin] = + projects += 1 + BinF.basis.project(s) + def embed(fs: BinF[Bin]): Bin = + embeds += 1 + BinF.basis.embed(fs) + + val c = new Counting + // fold Bin --leafSum--> Int, then unfold Int --binSpine--> Bin (a left spine of n Branches). + val binSpine: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) + val m = Schemes.meta[BinF, Bin, Int, BinF, Bin](leafSum, binSpine)(using + BinF.traverse, + c, + BinF.traverse, + c, + ) + val _ = m.get(tree) // neck 6 → a 6-deep Bin spine + + (c.projects must be_>(0)).and(c.embeds must be_>(0)) + } + + // ----- metaChrono: the same seam at the universal indices (histo → futu) ----- + + "metaChrono folds course-of-value (histo) then multi-layer-unfolds (futu)" >> { + // heads-only histo == leafSum; all-Pure futu == spine — so metaChrono == meta here. + val histoSum: BinF[Attr[BinF, Int]] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l.head + r.head + val futuSpine: Int => RoseF[Coattr[RoseF, Int]] = + n => if n <= 0 then RoseF(0, Nil) else RoseF(n, List(Coattr.Pure(n - 1))) + + val viaChrono = Schemes.metaChrono[BinF, Bin, Int, RoseF, Rose](histoSum, futuSpine) + val viaMeta = Schemes.meta[BinF, Bin, Int, RoseF, Rose](leafSum, spine) + val trees = List(tree, Bin.Leaf(4), Bin.Branch(Bin.Leaf(5), Bin.Leaf(6))) + trees.map(viaChrono.get) === trees.map(viaMeta.get) + } + + "histo.meta(futu) seam == Schemes.metaChrono" >> { + val histoSum: BinF[Attr[BinF, Int]] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l.head + r.head + val futuSpine: Int => RoseF[Coattr[RoseF, Int]] = + n => if n <= 0 then RoseF(0, Nil) else RoseF(n, List(Coattr.Pure(n - 1))) + val viaSeam = + Schemes.histo[BinF, Bin, Int](histoSum).meta(Schemes.futu[RoseF, Int, Rose](futuSpine)) + val viaCtor = Schemes.metaChrono[BinF, Bin, Int, RoseF, Rose](histoSum, futuSpine) + val trees = List(tree, Bin.Branch(Bin.Leaf(7), Bin.Leaf(8))) + trees.map(viaSeam.get) === trees.map(viaCtor.get) + } + + // ----- stack-safety across both passes: 10^6 ----- + + "meta is stack/space-safe: fold a 10^6-deep Bin, unfold a 10^6-node Rose" >> { + val Deep = 1_000_000 + var b: Bin = Bin.Leaf(0) + var i = 0 + while i < Deep do + b = Bin.Branch(b, Bin.Leaf(0)) + i += 1 + val depth: BinF[Int] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + val m = Schemes.meta[BinF, Bin, Int, RoseF, Rose](depth, spine) + val built: Rose = m.get(b) // neck = Deep → Rose spine of Deep+1 nodes + Schemes.cata[RoseF, Rose, Int](countRose).get(built) === Deep + 1 + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala new file mode 100644 index 00000000..5eb41073 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala @@ -0,0 +1,65 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import org.specs2.mutable.Specification + +import optics.Lens +import schemes.samples.{Bin, BinF} + +// Top-level (outer-accessor-safe) wrapper so the composition case can put a core Lens *above* the +// scheme Lens. +final case class Box(t: Bin) + +/** Step 3: [[Schemes.paraLens]] — the paramorphism promoted to a writable `Lens`. `get` is a + * subterm-retaining fold; `enplace` is the caller-supplied coherent put. The point of the spike: a + * recursion scheme that is a genuine, lawful Lens, composing with core's Lenses. + * + * The fixture is the "leftmost leaf" lens: `get` folds down the left spine (a paramorphism that + * keeps only the left child's result), `enplace` rewrites that same leaf. A coherent pair, so the + * three Lens laws hold. + */ +class ParaLensSpec extends Specification: + + // get = leftmost leaf value, via a paramorphism (BranchF keeps the *left* child's fold result). + private val leftmostAlg: BinF[(Bin, Int)] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF((_, l), _) => l + + // enplace = rewrite the leftmost leaf — the coherent inverse direction. + private def setLeftmost(s: Bin, v: Int): Bin = s match + case Bin.Leaf(_) => Bin.Leaf(v) + case Bin.Branch(l, r) => Bin.Branch(setLeftmost(l, v), r) + + private val leftmost = Schemes.paraLens[BinF, Bin, Int](leftmostAlg)(setLeftmost) + + private val tree: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Leaf(3)) + + "get is the paramorphism: the leftmost leaf" >> { + leftmost.get(tree) === 1 + } + + "Lens law — get-put (replacing with what you read is a no-op)" >> { + leftmost.replace(leftmost.get(tree))(tree) === tree + } + + "Lens law — put-get (reading what you wrote returns it)" >> { + leftmost.get(leftmost.replace(99)(tree)) === 99 + } + + "Lens law — put-put (a second write wins)" >> { + leftmost.replace(2)(leftmost.replace(1)(tree)) === leftmost.replace(2)(tree) + } + + "modify lifts a function over the focus" >> { + leftmost.modify(_ + 10)(tree) === Bin.Branch(Bin.Branch(Bin.Leaf(11), Bin.Leaf(2)), Bin.Leaf(3)) + } + + "composes with a core Lens on the fused Tuple2 path: Box → leftmost leaf" >> { + val boxRoot = Lens[Box, Bin](_.t, (b, t) => b.copy(t = t)) + val composed = boxRoot.andThen(leftmost) // a Lens[Box, Int] focusing the leftmost leaf + (composed.get(Box(tree)) === 1) + .and(composed.replace(7)(Box(tree)) === Box(setLeftmost(tree, 7))) + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ParaRouteSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ParaRouteSpec.scala new file mode 100644 index 00000000..bb829bd3 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ParaRouteSpec.scala @@ -0,0 +1,62 @@ +package dev.constructive.eo +package schemes + +import org.specs2.mutable.Specification + +import optics.Optic.* // get +import schemes.samples.{Bin, BinF} + +/** Pins [[Schemes.para]]'s *route*, not just its result: the retained subterms must come off the + * layer the machine already peeled, so a para fold peels each node **exactly once**. + * + * The alternative — recovering the subterms by re-`project`ing each node (or materializing each + * node's children into a `List`) — peels every node twice and allocates an extra layer per node on + * top of the `List`; that is the regression this spec exists to catch (on the 8 191-node benchmark + * fixture it costs ~2x para's allocation, past droste's `zoo.para`). + */ +class ParaRouteSpec extends Specification: + + sequential + + // 7 nodes: 4 leaves + 3 branches. + private val tree: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Branch(Bin.Leaf(3), Bin.Leaf(4))) + + /** Counts layer peels; a fold never calls `embed`. */ + final private class CountingBasis extends Basis[BinF, Bin]: + var projects = 0 + + def project(s: Bin): BinF[Bin] = + projects += 1 + s match + case Bin.Leaf(n) => BinF.LeafF(n) + case Bin.Branch(l, r) => BinF.BranchF(l, r) + + def embed(fs: BinF[Bin]): Bin = + fs match + case BinF.LeafF(n) => Bin.Leaf(n) + case BinF.BranchF(l, r) => Bin.Branch(l, r) + + private val subtermSum: BinF[(Bin, Int)] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF((_, l), (_, r)) => l + r + + "para peels each node exactly once: subterms come off the machine already-peeled layer" >> { + val basis = new CountingBasis + val sum = Schemes.para[BinF, Bin, Int](subtermSum)(using BinF.traverse, basis).get(tree) + + (sum === 10).and(basis.projects === 7) // 7 nodes folded => 7 peels: no per-node re-project + } + + "para reads the original subterms, not just the children results" >> { + // Only a subterm-retaining fold can compute this: each branch adds its LEFT child leaf + // weight when that child is itself a leaf — a plain cata sees results only. + val leftLeafWeight: BinF[(Bin, Int)] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF((ls, l), (_, r)) => + l + r + (ls match { case Bin.Leaf(w) => w; case _ => 0 }) + + // Branch(Branch(1, 2), Branch(3, 4)): the two inner branches are the left-leaf cases => + // (1 + 2 + 1) + (3 + 4 + 3) = 14. + Schemes.para[BinF, Bin, Int](leftLeafWeight).get(tree) === 14 + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/PlatedBridgeSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/PlatedBridgeSpec.scala new file mode 100644 index 00000000..027dfc96 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/PlatedBridgeSpec.scala @@ -0,0 +1,60 @@ +package dev.constructive.eo +package schemes + +import org.specs2.mutable.Specification + +import optics.Plated +import schemes.samples.{Bin, BinF} + +/** The Step-1 bridge: a typed pattern-functor [[Basis]] (the schemes' `S`↔`F` correspondence) feeds + * core's [[optics.Plated.fromBasis]], so the same `Basis[BinF, Bin]` that drives `cata`/`ana` also + * drives core's `Plated` recursion combinators (`children` / `universe` / `transform`). One + * correspondence, both worlds. + */ +class PlatedBridgeSpec extends Specification: + + // The bridge: derive the core Plated straight from the schemes' Basis. + private given Plated[Bin] = Plated.fromBasis[BinF, Bin] + + private val tree: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Leaf(3)) + + "fromBasis.children yields the immediate subterms in project order" >> { + Plated.children(tree) === List(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Leaf(3)) + } + + "fromBasis.universe enumerates the whole tree (self first, pre-order)" >> { + Plated.universe(tree) === List( + tree, + Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), + Bin.Leaf(1), + Bin.Leaf(2), + Bin.Leaf(3), + ) + } + + "fromBasis.transform rewrites every node bottom-up" >> { + val bumped = Plated.transform[Bin] { + case Bin.Leaf(n) => Bin.Leaf(n + 10) + case b => b + }(tree) + bumped === Bin.Branch(Bin.Branch(Bin.Leaf(11), Bin.Leaf(12)), Bin.Leaf(13)) + } + + "fromBasis rebuild is identity (the embed∘project coherence the derivation rests on)" >> { + Plated.transform[Bin](identity)(tree) === tree + } + + "fromBasis.universe sum agrees with a cata leaf-sum over the same Basis" >> { + val viaPlated = Plated + .universe(tree) + .collect { case Bin.Leaf(n) => n } + .sum + val viaCata = Schemes + .cata[BinF, Bin, Int] { + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + } + .get(tree) + (viaPlated === viaCata).and(viaPlated === 6) + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala new file mode 100644 index 00000000..baadb5db --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala @@ -0,0 +1,158 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import cats.instances.option.* +import cats.{Eval, Id} +import org.specs2.mutable.Specification + +import optics.Optic.* // get, reverseGet +import schemes.samples.{Bin, BinF} +import schemes.zoo.{Attr, Coattr} + +/** Behaviour spec for the monadic (`*M`) scheme family — [[Schemes.cataM]] / `paraM` / `histoM` / + * `anaM` / `apoM` / `futuM` / `hyloM` / `chronoM`, all riding `Machines.foldLayeredM`. + * + * Two anchors per scheme: + * + * - '''Agreement at `M = Id`''' — the effectful scheme with the identity monad reproduces its + * pure twin exactly (the cross-architecture pin: pure engine vs `Monad`-lifted engine agree). + * - '''Real effect''' — threading `Option` short-circuits the whole fold/build to `None` when + * any node aborts, which the pure scheme cannot express. + * + * Plus stack-safety: the `M` engine frames every node on a heap `ArrayDeque` and loops through + * `tailRecM`, so a lawful stack-safe `M` (`Eval`) folds/builds 10⁶-deep with no native-stack use. + */ +class SchemesMSpec extends Specification: + + sequential + + private val tree: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Branch(Bin.Leaf(3), Bin.Leaf(4))) + + private val withNeg: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(-2)), Bin.Leaf(3)) + + private val sumLeaves: BinF[Int] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + private val depthAlg: BinF[Int] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + + private def deepSpine(n: Int): Bin = + var b: Bin = Bin.Leaf(0) + var i = 0 + while i < n do + b = Bin.Branch(b, Bin.Leaf(0)) + i += 1 + b + + // ----- cataM ----- + + "cataM at M = Id reproduces cata" >> { + val viaM: Id[Int] = Schemes.cataM[Id, BinF, Bin, Int](sumLeaves).get(tree) + viaM === Schemes.cata[BinF, Bin, Int](sumLeaves).get(tree) + } + + "cataM threads Option, short-circuiting the whole fold when a node aborts" >> { + val alg: BinF[Int] => Option[Int] = + case BinF.LeafF(n) => if n < 0 then None else Some(n) + case BinF.BranchF(l, r) => Some(l + r) + (Schemes.cataM[Option, BinF, Bin, Int](alg).get(tree) === Some(10)) + .and(Schemes.cataM[Option, BinF, Bin, Int](alg).get(withNeg) === None) + } + + // ----- paraM / histoM (the comonad-tower indices, M-lifted) ----- + + "paraM at M = Id reproduces para" >> { + val alg: BinF[(Bin, Int)] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF((_, l), (_, r)) => l + r + val viaM: Id[Int] = Schemes.paraM[Id, BinF, Bin, Int](alg).get(tree) + viaM === Schemes.para[BinF, Bin, Int](alg).get(tree) + } + + "histoM at M = Id reproduces histo" >> { + val alg: BinF[Attr[BinF, Int]] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l.head + r.head + val viaM: Id[Int] = Schemes.histoM[Id, BinF, Bin, Int](alg).get(tree) + viaM === Schemes.histo[BinF, Bin, Int](alg).get(tree) + } + + // ----- anaM / apoM / futuM ----- + + private val anaCoalg: Int => BinF[Int] = + n => if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + + "anaM at M = Id reproduces ana" >> { + val viaM: Id[Bin] = Schemes.anaM[Id, BinF, Int, Bin](anaCoalg).reverseGet(6) + viaM === Schemes.ana[BinF, Int, Bin](anaCoalg).reverseGet(6) + } + + "anaM threads Option, short-circuiting the whole build when a seed aborts" >> { + val coalg: Int => Option[BinF[Int]] = + n => if n < 0 then None else Some(anaCoalg(n)) + (Schemes.anaM[Option, BinF, Int, Bin](coalg).reverseGet(6) === + Some(Schemes.ana[BinF, Int, Bin](anaCoalg).reverseGet(6))) + .and(Schemes.anaM[Option, BinF, Int, Bin](_ => None).reverseGet(6) === None) + } + + "apoM at M = Id reproduces apo (Left grafts a finished subtree)" >> { + val coalg: Int => BinF[Either[Bin, Int]] = + a => if a <= 0 then BinF.LeafF(0) else BinF.BranchF(Left(Bin.Leaf(99)), Right(a - 1)) + val viaM: Id[Bin] = Schemes.apoM[Id, BinF, Int, Bin](coalg).reverseGet(2) + viaM === Schemes.apo[BinF, Int, Bin](coalg).reverseGet(2) + } + + "futuM at M = Id reproduces futu (Roll unrolls a prebuilt layer)" >> { + val coalg: Int => BinF[Coattr[BinF, Int]] = n => + if n <= 0 then BinF.LeafF(n) + else + BinF.BranchF( + Coattr.Roll(BinF.BranchF(Coattr.Pure(0), Coattr.Pure(0))), + Coattr.Pure(n - 1), + ) + val viaM: Id[Bin] = Schemes.futuM[Id, BinF, Int, Bin](coalg).reverseGet(1) + viaM === Schemes.futu[BinF, Int, Bin](coalg).reverseGet(1) + } + + // ----- hyloM / chronoM (fused) ----- + + "hyloM at M = Id reproduces hylo, and Option short-circuits the fused refold" >> { + val viaM: Id[Int] = Schemes.hyloM[Id, BinF, Int, Int](anaCoalg, sumLeaves).get(6) + val idOk = viaM === Schemes.hylo[BinF, Int, Int](anaCoalg, sumLeaves).get(6) + val coalgOpt: Int => Option[BinF[Int]] = n => Some(anaCoalg(n)) + val algOpt: BinF[Int] => Option[Int] = + case BinF.LeafF(n) => if n < 0 then None else Some(n) + case BinF.BranchF(l, r) => Some(l + r) + idOk.and(Schemes.hyloM[Option, BinF, Int, Int](coalgOpt, algOpt).get(6) must beSome) + } + + "chronoM at M = Id reproduces chrono" >> { + val coalg: Int => BinF[Coattr[BinF, Int]] = + n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Coattr.Pure(n - 1), Coattr.Pure(-1)) + val alg: BinF[Attr[BinF, Int]] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l.head, r.head) + val viaM: Id[Int] = Schemes.chronoM[Id, BinF, Int, Int](coalg, alg).get(8) + viaM === Schemes.chrono[BinF, Int, Int](coalg, alg).get(8) + } + + // ----- stack-safety of the M engine (Eval, 10^6) ----- + + "cataM is stack-safe folding a 10^6-deep spine through Eval" >> { + val deep = deepSpine(1_000_000) + Schemes.cataM[Eval, BinF, Bin, Int](layer => Eval.now(depthAlg(layer))).get(deep).value === + 1_000_000 + } + + "anaM is stack-safe building a 10^6-deep spine through Eval (folded back to check)" >> { + val coalg: Int => Eval[BinF[Int]] = + n => Eval.now(if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1)) + val built: Bin = Schemes.anaM[Eval, BinF, Int, Bin](coalg).reverseGet(1_000_000).value + Schemes.cata[BinF, Bin, Int](depthAlg).get(built) === 1_000_000 + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala index d849dccd..fd1a2a9b 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -1,238 +1,161 @@ package dev.constructive.eo package schemes -import scala.annotation.tailrec +import scala.language.implicitConversions -import io.circe.Json import org.specs2.mutable.Specification -import data.PSVec -import optics.{Getter, Plated, Review, Unfold} -import optics.Optic.* // cross, andThen, get -import generics.plate -import circe.platedJson -import schemes.samples.{Expr, Wrapped} - +import data.MultiFocus +import optics.{Getter, Optic} +import optics.Optic.* // get, readOnly, reverseGet, foldMap, modify, andThen +import schemes.samples.{Bin, BinF, Rose, RoseF} + +/** Behaviour spec for the node-blind recursion-scheme spine (`cata` / `ana` / `hylo`) and `fLayer`. + * + * Type-safety note: every `alg`/`coalg` pattern-matches `F`'s *named* constructors (`case + * BinF.BranchF(l, r) => l + r`), with `l`/`r` typed `A` — there is no `kids(0)`/`AnyRef` + * positional path, so a child-arity mismatch is a compile error, not a runtime `IndexOutOfBounds`. + * + * `cata` is **node-blind** here (`alg: F[A] => A`): the algebra never sees the source node, only + * its folded children. A node-reading fold is a paramorphism — a follow-up scheme, not one of the + * three. + */ class SchemesSpec extends Specification: - private given Plated[Expr] = plate[Expr] - - // ----- algebras / expansions ----- - - private val eval: (Expr, PSVec[Double]) => Double = (node, kids) => - node match - case Expr.Lit(v) => v - case Expr.Neg(_) => -kids(0) - case Expr.Add(_, _) => kids(0) + kids(1) - case Expr.Mul(_, _) => kids(0) * kids(1) + // Deep examples: one-at-a-time to bound peak heap (shared test JVM). + sequential - // seed n expands to two child seeds (0, n-1) — a right-nested binary spine; n<=0 is a leaf. - private val expandFib: Int => PSVec[Int] = n => - if n <= 0 then PSVec.empty[Int] else PSVec.of(0, n - 1) + // A small mixed tree: Branch(Leaf 1, Branch(Leaf 2, Leaf 3)) — leaf sum 6, 3 leaves, depth 2. + private val tree: Bin = + Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) - // bundled coalgebra for ana: each seed's child seeds + how to assemble the Expr node. - // Builds a right-nested Add of (n+1) ones → evaluates to n+1. - private val buildExprCoalg: Schemes.Coalg[Int, Expr] = n => - if n <= 0 then (PSVec.empty[Int], (_: PSVec[Expr]) => Expr.Lit(1.0)) - else (PSVec.of(0, n - 1), (ks: PSVec[Expr]) => Expr.Add(ks(0), ks(1))) + // node-blind leaf-sum algebra (sees only the folded children, never the Bin) + private val sumLeaves: BinF[Int] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r - // the same computation FUSED (folds to Double directly; no Expr built) — hylo's split form. - private val fusedFib: (Int, PSVec[Double]) => Double = (n, rs) => - if n <= 0 then 1.0 else rs(0) + rs(1) + private val depthAlg: BinF[Int] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) - private val expr: Expr = Expr.Add(Expr.Lit(1.0), Expr.Mul(Expr.Lit(2.0), Expr.Lit(3.0))) + // ----- cata (typed node-blind fold) ----- - "cata is a Getter that folds an Expr to a value" >> { - val evalG: Getter[Expr, Double] = Schemes.cata(eval) - (evalG.get(expr) == 7.0) must beTrue + "cata folds a Bin to a value through F's named constructors" >> { + (Schemes.cata[BinF, Bin, Int](sumLeaves).get(tree) == 6) must beTrue } - "cata folds to a result type other than S (A != S)" >> { - val size: (Expr, PSVec[Int]) => Int = (_, kids) => 1 + kids.toList.sum - (Schemes.cata(size).get(expr) == 5) must beTrue // Add, Lit, Mul, Lit, Lit + "cata handles a single leaf (no recursive positions)" >> { + (Schemes.cata[BinF, Bin, Int](sumLeaves).get(Bin.Leaf(7)) == 7) must beTrue } - "cata consumes a pure algebra carried as an Unfold citizen (incl. one built by composition)" >> { - val sizeAlg: Unfold[Int, Int, PSVec] = - Unfold.algebra[Int, Int, PSVec](kids => 1 + kids.toList.sum) - (Schemes.cata[Expr, Int](sizeAlg).get(expr) == 5) must beTrue - - // an algebra assembled by optic composition: Review post-processes each layer's result, - // so the engine consumes `2 * max(sum(kids), 1)` without that lambda being written anywhere - val doubled: Unfold[Int, Int, PSVec] = - Review[Int, Int](_ * 2).andThen(Unfold.algebra[Int, Int, PSVec](_.toList.sum.max(1))) - // Lit leaf: 2*max(0,1) = 2; Neg node: 2*max(2,1) = 4 - (Schemes.cata[Expr, Int](doubled).get(Expr.Neg(Expr.Lit(1.0))) == 4) must beTrue + "cata handles a one-level Branch(Leaf, Leaf)" >> { + (Schemes.cata[BinF, Bin, Int](sumLeaves).get(Bin.Branch(Bin.Leaf(4), Bin.Leaf(5))) == 9) must + beTrue } - "cata-as-Getter composes onto an outer Getter via andThen" >> { - val composed: Getter[Wrapped, Double] = - Getter[Wrapped, Expr](_.expr).andThen(Schemes.cata(eval)) - (composed.get(Wrapped("x", expr)) == 7.0) must beTrue + "cata-as-read composes onto an outer Getter via andThen (Direct carrier, no bridge)" >> { + val composed: Getter[(String, Bin), Int] = + Getter[(String, Bin), Bin](_._2).andThen(Schemes.cata[BinF, Bin, Int](sumLeaves)) + (composed.get(("x", tree)) == 6) must beTrue } - "ana is a Review that builds an Expr from a seed" >> { - val built: Expr = Schemes.ana(buildExprCoalg).reverseGet(3) - (Schemes.cata(eval).get(built) == 4.0) must beTrue - } + // ----- ana (typed build) ----- - "ana.cross(cata) composes build->read (the materializing hylo) via core `cross`" >> { - val refold = - Schemes.ana(buildExprCoalg).cross(Schemes.cata(eval)) // Optic[Int,Unit,Double,Unit,Direct] - (refold.get(3) == 4.0) must beTrue + "ana builds a Bin from a seed, then cata reads it back" >> { + // seed n: a left spine of n Branches ending in Leaf(1); right child always Leaf(0). + val spine: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(n - 1, -1) + val built: Bin = Schemes.ana[BinF, Int, Bin](spine).reverseGet(3) + // seed 3 -> 4 leaves of weight 1 → sum 4, depth 3 + (Schemes.cata[BinF, Bin, Int](sumLeaves).get(built) == 4) + .and(Schemes.cata[BinF, Bin, Int](depthAlg).get(built) == 3) } - "fused hylo folds a seed to a value (no intermediate Expr) and agrees with cata∘ana" >> { - val h: Getter[Int, Double] = Schemes.hylo(expandFib, fusedFib) - val viaCross = Schemes.ana(buildExprCoalg).cross(Schemes.cata(eval)).get(3) - (h.get(3) == 4.0) && (viaCross == 4.0) must beTrue + // ----- hylo (fused refold, no intermediate Bin) ----- + + "hylo fuses unfold+fold with no intermediate Bin built" >> { + val coalg: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1) + // seed 3 -> a right spine of 4 leaves + (Schemes.hylo[BinF, Int, Int](coalg, sumLeaves).get(3) == 4) must beTrue } - "the fused hylo Getter composes further into the pipeline" >> { + "hylo composes further into the pipeline" >> { + val coalg: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1) val toStr: Getter[Int, String] = - Schemes.hylo(expandFib, fusedFib).andThen(Getter[Double, String](_.toString)) - (toStr.get(3) == "4.0") must beTrue + Schemes + .hylo[BinF, Int, Int](coalg, sumLeaves) + .readOnly + .andThen(Getter[Int, String](_.toString)) + (toStr.get(3) == "4") must beTrue } - // The combine indexes children positionally (kids(0) = left, kids(1) = right), so the engine's - // post-order out-array fill MUST preserve child order. Every other algebra here is commutative - // (Add/Mul/sum), which would not catch a transposed-children regression — this one is not. - "cata preserves left-to-right child order (non-commutative algebra)" >> { - // reinterpret Add as subtraction, Mul as division — both order-sensitive. - val sub: (Expr, PSVec[Double]) => Double = (node, kids) => - node match - case Expr.Lit(v) => v - case Expr.Neg(_) => -kids(0) - case Expr.Add(_, _) => kids(0) - kids(1) - case Expr.Mul(_, _) => kids(0) / kids(1) - val minus = Expr.Add(Expr.Lit(10.0), Expr.Lit(3.0)) // 10 - 3 = 7, NOT 3 - 10 = -7 - val div = Expr.Mul(Expr.Lit(12.0), Expr.Lit(4.0)) // 12 / 4 = 3, NOT 4 / 12 - ((Schemes.cata(sub).get(minus) == 7.0) && (Schemes.cata(sub).get(div) == 3.0)) must beTrue - } + // ----- fLayer: the single-layer MultiFocus[F] self-traversal ----- - // All sample ADT nodes are arity <= 2; this exercises the n-ary (width 3) expand path through the - // engine's per-node out-array, with positional weights that expose any mis-ordering. - "fused hylo folds a WIDE node (3 child seeds), order-sensitive combine" >> { - val expandWide: Int => PSVec[Int] = - n => if n <= 0 then PSVec.empty[Int] else PSVec.from(List(0, 0, 0)) - val combineWide: (Int, PSVec[Int]) => Int = - (n, rs) => if n <= 0 then 1 else rs(0) + 10 * rs(1) + 100 * rs(2) - // n=1 → three leaves (each 1) → 1 + 10 + 100 = 111 - (Schemes.hylo(expandWide, combineWide).get(1) == 111) must beTrue + "fLayer is a usable Optic[S,S,S,S,MultiFocus[F]]: to/from round-trip one layer" >> { + val layer: Optic[Bin, Bin, Bin, Bin, MultiFocus[BinF]] = Schemes.fLayer[BinF, Bin] + val b = Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)) + (layer.from(layer.to(b)) == b) must beTrue } - // ----- circe Plated[Json] (real downstream target) ----- - - "cata works over circe's Plated[Json] (sum every number in a document)" >> { - val sumNumbers: (Json, PSVec[Int]) => Int = - (j, kids) => j.asNumber.flatMap(_.toInt).getOrElse(0) + kids.toList.sum - val doc = Json.obj( - "a" -> Json.fromInt(1), - "b" -> Json.arr(Json.fromInt(2), Json.fromInt(3)), - "c" -> Json.fromString("ignored"), - ) - (Schemes.cata(sumNumbers).get(doc) == 6) must beTrue + "fLayer reads its layer's immediate foci via foldMap (Foldable[BinF])" >> { + val layer = Schemes.fLayer[BinF, Bin] + (layer.foldMap[Int](_ => 1)(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2))) == 2) + .and(layer.foldMap[Int](_ => 1)(Bin.Leaf(9)) == 0) // a leaf has no recursive foci } - "ana builds a nested circe Json from a seed" >> { - val buildJsonCoalg: Schemes.Coalg[Int, Json] = n => - if n <= 0 then (PSVec.empty[Int], (_: PSVec[Json]) => Json.fromInt(0)) - else (PSVec.singleton(n - 1), (ks: PSVec[Json]) => Json.arr(ks(0))) - val built = Schemes.ana(buildJsonCoalg).reverseGet(2) - (built == Json.arr(Json.arr(Json.fromInt(0)))) must beTrue + "fLayer WRITES now: modify rewrites the immediate children (the MultiFocus upgrade)" >> { + val layer = Schemes.fLayer[BinF, Bin] + val b = Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)) + // one-layer rewrite: replace each immediate child, leaving the layer's shape intact. + layer.modify(_ => Bin.Leaf(0))(b) === Bin.Branch(Bin.Leaf(0), Bin.Leaf(0)) } - // ----- stack-safety (the win over a hand-written one-off) ----- - - "cata is stack-safe over a depth-10^6 Neg spine" >> { - @tailrec def negSpine(e: Expr, i: Int): Expr = - if i < 1_000_000 then negSpine(Expr.Neg(e), i + 1) else e - val e = negSpine(Expr.Lit(0.0), 0) - val depth: (Expr, PSVec[Int]) => Int = (node, kids) => - node match - case Expr.Lit(_) => 0 - case _ => kids(0) + 1 - (Schemes.cata(depth).get(e) == 1_000_000) must beTrue - } + // ----- stack-safety: 10^6 deep ----- + // + // The foldLayered machine (the < 512-on-stack / heap-ArrayDeque hybrid) moves the deep recursion + // off the JVM call stack, so these complete without StackOverflowError where naive recursion + // overflows — in O(depth) space (no Eval chain), so they run in the default test heap. + + private val Deep = 1_000_000 - "fused hylo is stack-safe at depth 10^6 (no intermediate S built)" >> { - val expandSpine: Int => PSVec[Int] = - n => if n <= 0 then PSVec.empty[Int] else PSVec.singleton(n - 1) - val depthAlg: (Int, PSVec[Int]) => Int = (n, rs) => if n <= 0 then 0 else rs(0) + 1 - (Schemes.hylo(expandSpine, depthAlg).get(1_000_000) == 1_000_000) must beTrue + "cata is stack/space-safe folding a 10^6-deep Bin spine" >> { + var b: Bin = Bin.Leaf(0) + var i = 0 + while i < Deep do + b = Bin.Branch(b, Bin.Leaf(0)) + i += 1 + (Schemes.cata[BinF, Bin, Int](depthAlg).get(b) == Deep) must beTrue } - "ana's unfold loop is stack-safe (built S is O(depth) heap, not JVM stack)" >> { - val buildNegCoalg: Schemes.Coalg[Int, Expr] = n => - if n <= 0 then (PSVec.empty[Int], (_: PSVec[Expr]) => Expr.Lit(0.0)) - else (PSVec.singleton(n - 1), (ks: PSVec[Expr]) => Expr.Neg(ks(0))) - val deep: Expr = Schemes.ana(buildNegCoalg).reverseGet(100_000) - val depth: (Expr, PSVec[Int]) => Int = (node, kids) => - node match - case Expr.Lit(_) => 0 - case _ => kids(0) + 1 - (Schemes.cata(depth).get(deep) == 100_000) must beTrue + "hylo is stack/space-safe at depth 10^6 (no intermediate Bin)" >> { + val coalg: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) + (Schemes.hylo[BinF, Int, Int](coalg, depthAlg).get(Deep) == Deep) must beTrue } - // ----- on-stack MECHANISM below OnStackLimit (not just below-limit correctness) ----- - // - // The tests above pin VALUES at depths beyond OnStackLimit (they'd pass even if the - // engine always ran on the heap machine). These pin the MECHANISM claimed at - // Schemes.scala:26-28 ("a `< 512`-deep on-stack fast path ... Shallow trees pay no frame - // allocation"): well below the 512 limit, the engine must actually recurse on the JVM - // call stack, not immediately hand every node to the heap `ArrayDeque` trampoline. Each - // engine's `expand`/`coalg`/`alg` callback records `Thread.currentThread().getStackTrace - // .length` on every invocation over a depth-50 linear chain; on-stack recursion nests one - // JVM frame per tree level, so max-observed - min-observed grows large (empirically ~100+ - // for depth 50, two frames/level: the outer rec call + its inner `loop`). The - // `depth >= OnStackLimit` -> `<` and `ConditionalExpression` -> `true` mutants make the - // very first call take the heap branch, so every node is visited from inside the heap - // loop's flat, roughly-constant-depth call chain instead — the delta collapses to a - // handful of frames. Relative (delta) assertion only: an absolute frame count is JVM/JIT - // fragile. - - private def stackDepth(): Int = Thread.currentThread().getStackTrace.length - - "hylo's on-stack fast path recurses on the JVM call stack below OnStackLimit" >> { - // covers: Schemes.scala:66 `depth >= OnStackLimit` -> `<` / ConditionalExpression -> `true` - var maxD = 0 - var minD = Int.MaxValue - val expandSpine: Int => PSVec[Int] = n => - val d = stackDepth() - if d > maxD then maxD = d - if d < minD then minD = d - if n <= 0 then PSVec.empty[Int] else PSVec.singleton(n - 1) - val depthAlg: (Int, PSVec[Int]) => Int = (n, rs) => if n <= 0 then 0 else rs(0) + 1 - ((Schemes.hylo(expandSpine, depthAlg).get(50) == 50) && (maxD - minD >= 30)) must beTrue + "ana is stack/space-safe building a 10^6-deep Bin (the OOM frontier)" >> { + val coalg: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) + val deep: Bin = Schemes.ana[BinF, Int, Bin](coalg).reverseGet(Deep) + (Schemes.cata[BinF, Bin, Int](depthAlg).get(deep) == Deep) must beTrue } - "ana's on-stack fast path recurses on the JVM call stack below OnStackLimit" >> { - // covers: Schemes.scala:124 `depth >= OnStackLimit` -> `<` / ConditionalExpression -> `true` - var maxD = 0 - var minD = Int.MaxValue - val buildNegCoalgProbed: Schemes.Coalg[Int, Expr] = n => - val d = stackDepth() - if d > maxD then maxD = d - if d < minD then minD = d - if n <= 0 then (PSVec.empty[Int], (_: PSVec[Expr]) => Expr.Lit(0.0)) - else (PSVec.singleton(n - 1), (ks: PSVec[Expr]) => Expr.Neg(ks(0))) - val built: Expr = Schemes.ana(buildNegCoalgProbed).reverseGet(50) - ((built != null) && (maxD - minD >= 30)) must beTrue + // ----- wide-and-deep: exercise the Traverse[F] sequencing a binary spine misses ----- + + "hylo stays safe on a wide-AND-deep RoseF (high fanout + deep)" >> { + val DeepRose = 100_000 + val Width = 8 + val coalg: Int => RoseF[Int] = + d => if d <= 0 then RoseF(0, Nil) else RoseF(d, (d - 1) :: List.fill(Width)(-1)) + val countNodes: RoseF[Int] => Int = fr => 1 + fr.kids.sum + val expected = (DeepRose + 1) + DeepRose * Width + (Schemes.hylo[RoseF, Int, Int](coalg, countNodes).get(DeepRose) == expected) must beTrue } - "cata's on-stack fast path recurses on the JVM call stack below OnStackLimit" >> { - // covers: Schemes.scala:191 `depth >= OnStackLimit` -> `<` / ConditionalExpression -> `true` - var maxD = 0 - var minD = Int.MaxValue - @tailrec def negSpine50(e: Expr, i: Int): Expr = - if i < 50 then negSpine50(Expr.Neg(e), i + 1) else e - val e = negSpine50(Expr.Lit(0.0), 0) - val depthAlgProbed: (Expr, PSVec[Int]) => Int = (node, kids) => - val d = stackDepth() - if d > maxD then maxD = d - if d < minD then minD = d - node match - case Expr.Lit(_) => 0 - case _ => kids(0) + 1 - ((Schemes.cata(depthAlgProbed).get(e) == 50) && (maxD - minD >= 30)) must beTrue + "ana builds and cata folds a wide-AND-deep Rose (N-ary Project/Embed)" >> { + val DeepRose = 20_000 + val Width = 4 + val coalg: Int => RoseF[Int] = + d => if d <= 0 then RoseF(0, Nil) else RoseF(d, (d - 1) :: List.fill(Width)(-1)) + val countNodes: RoseF[Int] => Int = fr => 1 + fr.kids.sum + val built: Rose = Schemes.ana[RoseF, Int, Rose](coalg).reverseGet(DeepRose) + val expected = (DeepRose + 1) + DeepRose * Width + (Schemes.cata[RoseF, Rose, Int](countNodes).get(built) == expected) must beTrue } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala new file mode 100644 index 00000000..2b5fe6fd --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala @@ -0,0 +1,203 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import org.specs2.mutable.Specification + +import optics.Optic.* // get, reverseGet +import schemes.samples.{Bin, BinF, Rose, RoseF} +import schemes.zoo.{Attr, Coattr} + +/** The extended zoo: the subterm-retaining fold ([[Schemes.para]]) and its build-side dual + * ([[Schemes.apo]]); the short-circuit / seed-reading refolds ([[Schemes.elgot]] / + * [[Schemes.coelgot]]); and the refold-quadrant diagonals ([[Schemes.dyna]] / [[Schemes.codyna]]). + * + * Each is pinned by its degeneration law (collapses to its plain dual when its extra power is + * unused) plus the capability that distinguishes it, and the graft-by-reference for `apo`. + */ +class ZooExtendedSpec extends Specification: + + sequential + + private val tree: Bin = + Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) + + private val sumLeaves: BinF[Int] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + private def isLeaf(b: Bin): Boolean = b match + case Bin.Leaf(_) => true + case _ => false + + // ----- para (X = F[(S, A)], retained subterms) ----- + + "para reads original subterms: count Branch nodes that have a Leaf immediate child" >> { + // Needs the subterm S, not just the folded A — a cata cannot see a child's *shape* here. + val leafParents: BinF[(Bin, Int)] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF((ls, lr), (rs, rr)) => + (if isLeaf(ls) then 1 else 0) + (if isLeaf(rs) then 1 else 0) + lr + rr + // root has a Leaf left child (+1); inner Branch has two Leaf children (+2) → 3. + Schemes.para[BinF, Bin, Int](leafParents).get(tree) === 3 + } + + "para degenerates to cata when the subterm half is ignored" >> { + val viaPara = Schemes.para[BinF, Bin, Int](fa => sumLeaves(BinF.traverse.map(fa)(_._2))) + viaPara.get(tree) === Schemes.cata[BinF, Bin, Int](sumLeaves).get(tree) + } + + // ----- apo (X = Either[S, A], by-reference graft) ----- + + "apo grafts a finished subtree BY REFERENCE (eq), never rebuilt" >> { + val grafted: Bin = Bin.Branch(Bin.Leaf(98), Bin.Leaf(99)) + def coalg(n: Int): BinF[Either[Bin, Int]] = + if n <= 0 then BinF.LeafF(7) + else BinF.BranchF(Left(grafted), Right(n - 1)) // left = finished subtree, grafted + val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(1) + val graftSlot = built match + case Bin.Branch(g, _) => g + case other => other + (graftSlot.asInstanceOf[AnyRef] eq grafted.asInstanceOf[AnyRef]) === true + } + + "apo degenerates to ana when every slot is Right" >> { + val plain: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(n - 1, -1) + val viaApo = + Schemes.apo[BinF, Int, Bin](n => BinF.traverse.map(plain(n))(Right(_))).reverseGet(3) + viaApo === Schemes.ana[BinF, Int, Bin](plain).reverseGet(3) + } + + // ----- elgot (short-circuit unfold) ----- + + "elgot short-circuits: a Left seed resolves directly, the fold combines the rest" >> { + val coalg: Int => Either[Int, BinF[Int]] = n => + if n < 0 then Left(100) // short-circuit: this seed IS 100 + else if n == 0 then Right(BinF.LeafF(1)) + else Right(BinF.BranchF(n - 1, -1)) // right child = -1 → short-circuits + // f(0)=1; f(k)=f(k-1)+100 → f(3) = 1 + 3*100 = 301 + Schemes.elgot[BinF, Int, Int](coalg, sumLeaves).get(3) === 301 + } + + "elgot degenerates to hylo when every seed is Right" >> { + val plain: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1) + val viaElgot = Schemes.elgot[BinF, Int, Int](n => Right(plain(n)), sumLeaves) + val seeds = List(1, 2, 3, 5) + seeds.map(viaElgot.get) === seeds.map(Schemes.hylo[BinF, Int, Int](plain, sumLeaves).get) + } + + // ----- coelgot (seed-reading fold) ----- + + "coelgot reads the originating seed in the fold" >> { + val coalg: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(0, n - 1) + // count nodes whose seed is even (the fold sees the seed `a`) + val alg: (Int, BinF[Int]) => Int = (a, fb) => + val here = if a % 2 == 0 then 1 else 0 + val below = fb match + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => l + r + here + below + // seeds visited: 3 → 2 → 1 → 0, plus a leaf seed 0 at the bottom of each BranchF(0, …). + // BranchF(0, n-1): left seed 0 (even), right seed n-1. So evens: every left-0 + the even seeds. + Schemes.coelgot[BinF, Int, Int](coalg, alg).get(3) must be_>(0) + } + + "coelgot degenerates to hylo when the seed is ignored" >> { + val coalg: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1) + val viaCoelgot = Schemes.coelgot[BinF, Int, Int](coalg, (_, fb) => sumLeaves(fb)) + val seeds = List(1, 2, 3, 5) + seeds.map(viaCoelgot.get) === seeds.map(Schemes.hylo[BinF, Int, Int](coalg, sumLeaves).get) + } + + // ----- dyna (ana → histo) and codyna (futu → cata): refold-quadrant diagonals ----- + + "dyna == ana.cross(histo); degenerates to hylo heads-only" >> { + val coalg: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1) + val histoSum: BinF[Attr[BinF, Int]] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l.head + r.head + val seeds = List(1, 2, 3, 5) + val viaCtor = Schemes.dyna[BinF, Int, Int](coalg, histoSum) + val viaSeam = Schemes.ana[BinF, Int, Bin](coalg).cross(Schemes.histo[BinF, Bin, Int](histoSum)) + val viaHylo = Schemes.hylo[BinF, Int, Int](coalg, sumLeaves) // heads-only histo == cata + (seeds.map(viaCtor.get) === seeds.map(viaSeam.get)) + .and(seeds.map(viaCtor.get) === seeds.map(viaHylo.get)) + } + + "codyna == futu.cross(cata); degenerates to hylo all-Pure" >> { + val plain: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1) + val futuCoalg: Int => BinF[Coattr[BinF, Int]] = + n => BinF.traverse.map(plain(n))(Coattr.Pure(_)) + val seeds = List(1, 2, 3, 5) + val viaCtor = Schemes.codyna[BinF, Int, Int](futuCoalg, sumLeaves) + val viaSeam = + Schemes.futu[BinF, Int, Bin](futuCoalg).cross(Schemes.cata[BinF, Bin, Int](sumLeaves)) + val viaHylo = Schemes.hylo[BinF, Int, Int](plain, sumLeaves) + (seeds.map(viaCtor.get) === seeds.map(viaSeam.get)) + .and(seeds.map(viaCtor.get) === seeds.map(viaHylo.get)) + } + + // ----- stack-safety for the materialising members ----- + + "para and apo are stack/space-safe at depth 10^6" >> { + val Deep = 1_000_000 + // Scope each million-node tree in its own block so the first is collectable before the second + // is built — bounds peak heap in the shared test JVM (otherwise both live at once). + val paraOk = + var b: Bin = Bin.Leaf(0) + var i = 0 + while i < Deep do + b = Bin.Branch(b, Bin.Leaf(0)) + i += 1 + // para depth, reading only the result half (subterm ignored) — still walks the full spine. + val paraDepth: BinF[(Bin, Int)] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF((_, l), (_, r)) => 1 + math.max(l, r) + Schemes.para[BinF, Bin, Int](paraDepth).get(b) == Deep + val cataOk = + val apoCoalg: Int => BinF[Either[Bin, Int]] = + n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Right(n - 1), Right(-1)) + val deep: Bin = Schemes.apo[BinF, Int, Bin](apoCoalg).reverseGet(Deep) + Schemes.cata[BinF, Bin, Int](sumLeaves).get(deep) == 0 // all leaves are 0 + (paraOk must beTrue).and(cataOk must beTrue) + } + + // ----- review gaps: deep (>512) apo graft, and wide/variadic-functor para zip-alignment ----- + + "apo grafts by reference even past the on-stack limit (heapWalk Left arm)" >> { + val Deep = + 5_000 // well past OnStackLimit (512): exercises the heapWalk graft, not just on-stack + val grafted: Bin = Bin.Branch(Bin.Leaf(98), Bin.Leaf(99)) + // a left spine of `Deep` Branches; the deepest left child is the finished graft. + def coalg(n: Int): BinF[Either[Bin, Int]] = + if n <= 0 then BinF.BranchF(Left(grafted), Right(-1)) + else BinF.BranchF(Right(n - 1), Right(-1)) + // -1 → a leaf terminator + def coalg2(n: Int): BinF[Either[Bin, Int]] = + if n < 0 then BinF.LeafF(0) else coalg(n) + val built = Schemes.apo[BinF, Int, Bin](coalg2).reverseGet(Deep) + // walk down the left spine to the graft slot + var cur = built + var found: Bin = built + var steps = 0 + while steps <= Deep do + cur match + case Bin.Branch(l, _) => found = l; cur = l; steps += 1 + case _ => steps = Deep + 1 + // the graft is reached by reference, never rebuilt + (found.asInstanceOf[AnyRef] eq grafted.asInstanceOf[AnyRef]) === true + } + + "para's subterm zip stays aligned on a wide/variadic RoseF (map-order == fold-order)" >> { + // RoseF is N-ary (List of kids), so map-order vs fold-order alignment is genuinely exercised — + // unlike the fixed binary BinF. para must pair each kid's ORIGINAL subterm with its result. + val rose: Rose = Rose(0, List(Rose(1, Nil), Rose(2, List(Rose(3, Nil))), Rose(4, Nil))) + // For each node: sum of (label of each kid's original subterm) + recursive results. + // Reading the kid SUBTERM's label (not the folded result) is what needs the (S, A) pairing. + val alg: RoseF[(Rose, Int)] => Int = + fr => fr.kids.map { case (subterm, childResult) => subterm.label + childResult }.sum + // node 1: no kids → 0; node 3: 0; node 2: kid 3 → 3 + 0 = 3; node 4: 0; + // root 0: kids 1,2,4 → (1 + 0) + (2 + 3) + (4 + 0) = 1 + 5 + 4 = 10 + Schemes.para[RoseF, Rose, Int](alg).get(rose) === 10 + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala new file mode 100644 index 00000000..3d3c1b8f --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala @@ -0,0 +1,106 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import org.specs2.mutable.Specification + +import optics.Optic.* // get, reverseGet +import schemes.samples.{Bin, BinF} +import schemes.zoo.{Attr, Coattr} + +/** Behaviour + degeneration spec for the universal-index schemes: [[Schemes.histo]] (`X = Attr`, + * the cofree comonad) and [[Schemes.futu]] (`X = Coattr`, the free monad). + * + * - Degeneration: each collapses to its trivial-index dual when its decoration is unused — + * heads-only `histo == cata`, all-`Pure` `futu == ana`. + * - Real index: a course-of-value fold that reads grandchildren (unreachable by single-pass + * `cata`); a multi-layer unfold that `Roll`s a whole layer in one step (unreachable by `ana`). + * - Stack-safety to 10⁶ per scheme. + */ +class ZooSpec extends Specification: + + sequential + + private val tree: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Branch(Bin.Leaf(3), Bin.Leaf(4))) + + private val sumLeaves: BinF[Int] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + private val depthAlg: BinF[Int] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + + // ----- histo (X = Attr[F, A], cofree) ----- + + "heads-only histo degenerates to cata" >> { + val viaHisto = Schemes + .histo[BinF, Bin, Int](layer => sumLeaves(BinF.traverse.map(layer)(_.head))) + .get(tree) + viaHisto === Schemes.cata[BinF, Bin, Int](sumLeaves).get(tree) + } + + "histo reads real history: a leaf-sum that also reaches its grandchildren" >> { + // Unreachable by a single-pass cata: each branch adds its grandchildren's heads again, + // through the retained Attr history (course-of-value). + val cov = Schemes + .histo[BinF, Bin, Int] { + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => + def grand(attr: Attr[BinF, Int]): Int = attr.tail match + case BinF.LeafF(_) => 0 + case BinF.BranchF(gl, gr) => gl.head + gr.head + l.head + r.head + grand(l) + grand(r) + } + .get(tree) + // inner branches: 1+2=3 and 3+4=7 (their leaf children have no grandchildren); + // root: heads 3+7 plus grandchildren-through-history (1+2)+(3+4) = 20. + cov === 20 + } + + "histo is stack/space-safe folding a 10^6-deep Bin spine" >> { + val Deep = 1_000_000 + var b: Bin = Bin.Leaf(0) + var i = 0 + while i < Deep do + b = Bin.Branch(b, Bin.Leaf(0)) + i += 1 + val histoDepth: BinF[Attr[BinF, Int]] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l.head, r.head) + (Schemes.histo[BinF, Bin, Int](histoDepth).get(b) == Deep) must beTrue + } + + // ----- futu (X = Coattr[F, A], free) ----- + + "single-layer (all-Pure) futu degenerates to ana" >> { + val expand: Int => BinF[Int] = + n => if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + val viaFutu = Schemes + .futu[BinF, Int, Bin](n => BinF.traverse.map(expand(n))(Coattr.Pure(_))) + .reverseGet(6) + viaFutu === Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) + } + + "futu emits multiple layers per step: Roll unrolls a whole Branch with no coalgebra call" >> { + val coalg: Int => BinF[Coattr[BinF, Int]] = n => + if n <= 0 then BinF.LeafF(n) + else + BinF.BranchF( + Coattr.Roll(BinF.BranchF(Coattr.Pure(0), Coattr.Pure(0))), // two layers in one step + Coattr.Pure(n - 1), + ) + val built = Schemes.futu[BinF, Int, Bin](coalg).reverseGet(1) + built === Bin.Branch(Bin.Branch(Bin.Leaf(0), Bin.Leaf(0)), Bin.Leaf(0)) + } + + "futu is stack/space-safe building a 10^6-deep Bin (folded back for the check)" >> { + val Deep = 1_000_000 + val coalg: Int => BinF[Coattr[BinF, Int]] = n => + if n <= 0 then BinF.LeafF(0) + else BinF.BranchF(Coattr.Pure(n - 1), Coattr.Pure(-1)) + val built: Bin = Schemes.futu[BinF, Int, Bin](coalg).reverseGet(Deep) + (Schemes.cata[BinF, Bin, Int](depthAlg).get(built) == Deep) must beTrue + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala new file mode 100644 index 00000000..f1c20d25 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala @@ -0,0 +1,212 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import cats.arrow.FunctionK +import cats.~> +import org.specs2.mutable.Specification + +import optics.Optic.* // get, reverseGet +import schemes.samples.{Bin, BinF} + +/** Behaviour + degeneration spec for the schemes that complete the two index towers and the + * natural-transformation axis: + * + * - the comonad-tower rung [[Schemes.zygo]] (`X = F[(B, A)]`) and its generalisation + * [[Schemes.mutu]] (`X = F[(A, B)]`); + * - the build-side duals [[Schemes.cozygo]] (`X = Either[B, A]`) and [[Schemes.comutu]] (`X = + * Either[A, B]`); + * - the layer-transforming pair [[Schemes.prepro]] / [[Schemes.postpro]] (`η : F ~> F`). + * + * Each is anchored by the law that proves the generalisation correct — it collapses to a known + * scheme when its extra power is unused — plus a behaviour case that the base scheme could not + * express, plus stack-safety (10⁶ for the `O(n)` schemes; a heap-machine-crossing depth for the + * `O(n · depth)` pre/postpro). + */ +class ZooTowersSpec extends Specification: + + sequential + + private val tree: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Branch(Bin.Leaf(3), Bin.Leaf(4))) + + private val sumLeaves: BinF[Int] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + private val depthAlg: BinF[Int] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + + private def deepSpine(n: Int): Bin = + var b: Bin = Bin.Leaf(0) + var i = 0 + while i < n do + b = Bin.Branch(b, Bin.Leaf(0)) + i += 1 + b + + // ----- zygo (X = F[(B, A)], the store comonad over an auxiliary carrier) ----- + + private val sizeAux: BinF[Int] => Int = + case BinF.LeafF(_) => 1 + case BinF.BranchF(l, r) => 1 + l + r + + "B-blind zygo degenerates to cata" >> { + val viaZygo = Schemes + .zygo[BinF, Bin, Int, Int](sizeAux)(layer => sumLeaves(BinF.traverse.map(layer)(_._2))) + .get(tree) + viaZygo === Schemes.cata[BinF, Bin, Int](sumLeaves).get(tree) + } + + "zygo at B = S with aux = embed is exactly para" >> { + def countNodes(b: Bin): Int = b match + case Bin.Leaf(_) => 1 + case Bin.Branch(l, r) => 1 + countNodes(l) + countNodes(r) + val alg: BinF[(Bin, Int)] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF((lSub, lRes), (_, rRes)) => lRes + rRes + countNodes(lSub) + val viaZygo = Schemes.zygo[BinF, Bin, Int, Bin](BinF.basis.embed)(alg).get(tree) + viaZygo === Schemes.para[BinF, Bin, Int](alg).get(tree) + } + + "zygo reads its auxiliary result: leaf-sum plus each branch's left-subtree size" >> { + val alg: BinF[(Int, Int)] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF((sizeL, resL), (_, resR)) => resL + resR + sizeL + // inner branches: 1+2+1=4 and 3+4+1=8; root: 4+8+sizeL(3) = 15. + Schemes.zygo[BinF, Bin, Int, Int](sizeAux)(alg).get(tree) === 15 + } + + "zygo is stack-safe folding a 10^6-deep spine" >> { + val deep = deepSpine(1_000_000) + val depthMain: BinF[(Int, Int)] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF((_, l), (_, r)) => 1 + math.max(l, r) + Schemes.zygo[BinF, Bin, Int, Int](sizeAux)(depthMain).get(deep) === 1_000_000 + } + + // ----- mutu (X = F[(A, B)], mutual recursion) ----- + + "A-blind mutu degenerates to zygo (modulo the tuple flip)" >> { + // mutu with algB ignoring the A half == zygo(algB-as-aux)(algA). + val algB: BinF[(Int, Int)] => Int = // = sizeAux on the B half + case BinF.LeafF(_) => 1 + case BinF.BranchF((_, b1), (_, b2)) => 1 + b1 + b2 + val algA: BinF[(Int, Int)] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF((a1, b1), (a2, _)) => a1 + a2 + b1 + val viaMutu = Schemes.mutu[BinF, Bin, Int, Int](algA, algB).get(tree) + val viaZygo = Schemes + .zygo[BinF, Bin, Int, Int](sizeAux) { + case BinF.LeafF(n) => n + case BinF.BranchF((sizeL, resL), (_, resR)) => resL + resR + sizeL + } + .get(tree) + (viaMutu === viaZygo).and(viaMutu === 15) + } + + "mutu is genuinely mutual: each algebra reads the other's half" >> { + // A = signed sum where the sign of a branch flips when its B (node count) is even. + val count: BinF[(Int, Int)] => Int = + case BinF.LeafF(_) => 1 + case BinF.BranchF((_, b1), (_, b2)) => 1 + b1 + b2 + val signed: BinF[(Int, Int)] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF((a1, b1), (a2, _)) => + if (b1 % 2 == 0) a2 - a1 else a1 + a2 + // inner branches have count 3 (odd): 1+2=3 and 3+4=7; root left-count 3 (odd): 3+7 = 10. + Schemes.mutu[BinF, Bin, Int, Int](signed, count).get(tree) === 10 + } + + // ----- cozygo / g-apo (X = Either[B, A], build-side dual of zygo) ----- + + private val anaCoalg: Int => BinF[Int] = + n => if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + + "all-Right cozygo degenerates to ana" >> { + val viaCozygo = Schemes + .cozygo[BinF, Int, Int, Bin](_ => BinF.LeafF(0))(n => + BinF.traverse.map(anaCoalg(n))(Right(_)) + ) + .reverseGet(6) + viaCozygo === Schemes.ana[BinF, Int, Bin](anaCoalg).reverseGet(6) + } + + "cozygo unfolds its Left slots through the auxiliary coalgebra" >> { + val aux: Int => BinF[Int] = + b => if b <= 0 then BinF.LeafF(0) else BinF.BranchF(b - 1, b - 1) + val coalg: Int => BinF[Either[Int, Int]] = + a => if a <= 0 then BinF.LeafF(99) else BinF.BranchF(Left(1), Right(a - 1)) + val built = Schemes.cozygo[BinF, Int, Int, Bin](aux)(coalg).reverseGet(1) + built === Bin.Branch(Bin.Branch(Bin.Leaf(0), Bin.Leaf(0)), Bin.Leaf(99)) + } + + "cozygo is stack-safe building a 10^6-deep spine (all-Right path)" >> { + val coalg: Int => BinF[Either[Int, Int]] = + n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Right(n - 1), Right(-1)) + val built = Schemes.cozygo[BinF, Int, Int, Bin](_ => BinF.LeafF(0))(coalg).reverseGet(1_000_000) + Schemes.cata[BinF, Bin, Int](depthAlg).get(built) === 1_000_000 + } + + // ----- comutu (X = Either[A, B], build-side dual of mutu) ----- + + "single-coalgebra comutu degenerates to ana" >> { + val viaComutu = Schemes + .comutu[BinF, Int, Int, Bin]( + a => BinF.traverse.map(anaCoalg(a))(Left(_)), + (b: Int) => BinF.LeafF(b), + ) + .reverseGet(6) + viaComutu === Schemes.ana[BinF, Int, Bin](anaCoalg).reverseGet(6) + } + + "comutu alternates between its two coalgebras" >> { + val coalgA: Int => BinF[Either[Int, Int]] = + a => if a <= 0 then BinF.LeafF(0) else BinF.BranchF(Left(a - 1), Right(a)) + val coalgB: Int => BinF[Either[Int, Int]] = b => BinF.LeafF(b) + val built = Schemes.comutu[BinF, Int, Int, Bin](coalgA, coalgB).reverseGet(1) + built === Bin.Branch(Bin.Leaf(0), Bin.Leaf(1)) + } + + // ----- prepro / postpro (η : F ~> F, the layer-transforming axis) ----- + + private val incLeaf: BinF ~> BinF = new (BinF ~> BinF): + def apply[A](fa: BinF[A]): BinF[A] = fa match + case BinF.LeafF(n) => BinF.LeafF(n + 1) + case b @ BinF.BranchF(_, _) => b + + "prepro with η = id is exactly cata" >> { + Schemes.prepro[BinF, Bin, Int](FunctionK.id[BinF])(sumLeaves).get(tree) === + Schemes.cata[BinF, Bin, Int](sumLeaves).get(tree) + } + + "prepro applies η once per level: each leaf gains its depth" >> { + // leaves sit at depth 2, so η (which +1's a leaf) fires twice on each: sum 10 + 4*2 = 18. + Schemes.prepro[BinF, Bin, Int](incLeaf)(sumLeaves).get(tree) === 18 + } + + "prepro is stack-safe across the heap-machine boundary (O(n·depth), so not 10^6)" >> { + val deep = deepSpine(2_000) + Schemes.prepro[BinF, Bin, Int](FunctionK.id[BinF])(sumLeaves).get(deep) === 0 + } + + "postpro with η = id is exactly ana" >> { + val coalg: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, n - 1) + Schemes.postpro[BinF, Int, Bin](FunctionK.id[BinF])(coalg).reverseGet(2) === + Schemes.ana[BinF, Int, Bin](coalg).reverseGet(2) + } + + "postpro applies η once per level on the build side: each leaf gains its depth" >> { + val coalg: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, n - 1) + // seed 2 builds a depth-2 perfect tree of 4 zero-leaves; η lifts each by its depth (2): sum 8. + val built = Schemes.postpro[BinF, Int, Bin](incLeaf)(coalg).reverseGet(2) + Schemes.cata[BinF, Bin, Int](sumLeaves).get(built) === 8 + } + + "postpro is stack-safe across the heap-machine boundary (O(n·depth), so not 10^6)" >> { + val coalg: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) + val built = Schemes.postpro[BinF, Int, Bin](FunctionK.id[BinF])(coalg).reverseGet(2_000) + Schemes.cata[BinF, Bin, Int](depthAlg).get(built) === 2_000 + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/samples/PatternFunctors.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/samples/PatternFunctors.scala new file mode 100644 index 00000000..4283be48 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/samples/PatternFunctors.scala @@ -0,0 +1,78 @@ +package dev.constructive.eo.schemes.samples + +import cats.{Applicative, Eval, Traverse} +import dev.constructive.eo.schemes.Basis + +/** Sample recursive ADTs paired with their **pattern functors** for the typed recursion-scheme + * specs ([[dev.constructive.eo.schemes.Schemes.cata]] / `ana` / `hylo`). Top-level — NOT nested in + * a spec class — to mirror the other samples and stay clear of the generics outer-accessor rule. + * + * Each pattern functor carries its `Traverse` and a `Basis` (`Project` + `Embed`) in its + * companion, so the schemes resolve them with no extra import — exactly the shape a real user + * writes. The `Traverse.foldRight` instances are `Eval`-based (cats requires it) so they stay lazy + * under the driver's trampoline. + */ + +// ----- Bin: a binary tree, and its pattern functor BinF (recursion → A) ----- + +enum Bin: + case Leaf(n: Int) + case Branch(l: Bin, r: Bin) + +enum BinF[+A]: + case LeafF(n: Int) + case BranchF(l: A, r: A) + +object BinF: + + given traverse: Traverse[BinF] with + + def traverse[G[_]: Applicative, A, B](fa: BinF[A])(f: A => G[B]): G[BinF[B]] = + fa match + case BinF.LeafF(n) => Applicative[G].pure(BinF.LeafF(n)) + case BinF.BranchF(l, r) => Applicative[G].map2(f(l), f(r))(BinF.BranchF(_, _)) + + def foldLeft[A, B](fa: BinF[A], b: B)(f: (B, A) => B): B = + fa match + case BinF.LeafF(_) => b + case BinF.BranchF(l, r) => f(f(b, l), r) + + def foldRight[A, B](fa: BinF[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = + fa match + case BinF.LeafF(_) => lb + case BinF.BranchF(l, r) => f(l, Eval.defer(f(r, lb))) + + given basis: Basis[BinF, Bin] = Basis( + { + case Bin.Leaf(n) => BinF.LeafF(n) + case Bin.Branch(l, r) => BinF.BranchF(l, r) + }, + { + case BinF.LeafF(n) => Bin.Leaf(n) + case BinF.BranchF(l, r) => Bin.Branch(l, r) + }, + ) + +// ----- Rose: an N-ary tree (wide-and-deep), and its pattern functor RoseF ----- + +final case class Rose(label: Int, kids: List[Rose]) + +final case class RoseF[+A](label: Int, kids: List[A]) + +object RoseF: + + given traverse: Traverse[RoseF] with + + def traverse[G[_]: Applicative, A, B](fa: RoseF[A])(f: A => G[B]): G[RoseF[B]] = + Applicative[G].map(Traverse[List].traverse(fa.kids)(f))(ks => RoseF(fa.label, ks)) + + def foldLeft[A, B](fa: RoseF[A], b: B)(f: (B, A) => B): B = + Traverse[List].foldLeft(fa.kids, b)(f) + + def foldRight[A, B](fa: RoseF[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = + Traverse[List].foldRight(fa.kids, lb)(f) + + given basis: Basis[RoseF, Rose] = Basis( + r => RoseF(r.label, r.kids), + fr => Rose(fr.label, fr.kids), + ) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/samples/Samples.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/samples/Samples.scala deleted file mode 100644 index 65bb2e76..00000000 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/samples/Samples.scala +++ /dev/null @@ -1,17 +0,0 @@ -package dev.constructive.eo.schemes.samples - -/** Showcase ADTs for the schemes specs. Top-level — NOT nested in a spec class — because the - * eo-generics `plate[S]` macro emits `new V(...)`, which loses outer-accessor wiring for nested - * ADTs ("missing outer accessor"). Mirrors `tests/.../PlatedSpec.scala` and - * `generics/.../samples/package.scala`. - */ -enum Expr: - case Lit(value: Double) - case Neg(arg: Expr) - case Add(left: Expr, right: Expr) - case Mul(left: Expr, right: Expr) - -/** A non-recursive carrier holding an `Expr`, to show `cata`-as-`Getter` composing onto an outer - * optic via `andThen`. - */ -final case class Wrapped(label: String, expr: Expr) diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index 6e4a6dfa..23d74fbf 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -434,6 +434,95 @@ spine). Closing the last ~2–3× would mean fusing the recursion into the `plat macro — but that emits a *function*, not an `Optic`, which would break the `.andThen` composition `everywhere` relies on, so it's deliberately not done. +## Recursion schemes — the typed path vs droste and hand-written + +`SchemesBench` measures the typed recursion schemes (`cata` / `ana` / `hylo` and the +zoo — the `foldLayered` machine family, stack-safe to 10⁶ nodes) against +[droste](https://github.com/higherkindness/droste) and hand-written recursion over a +perfect binary `Bin` tree (8 191 nodes). An earlier untyped `PSVec` path was **removed** +once the typed path subsumed it (its erased positional indexing made algebra arity slips +a runtime error — the exact thing the typed path fixes). + +Core rows (CI runs 27398242244 + 27445302118, 2026-06-12/13, re-pinned at the merge candidate on +temurin@21 — every eo core row byte-identical to that sweep within ±2): + +| Method | B/op | vs droste (B/op) | +|---|--:|--:| +| `handCata` / `handHylo` | 0 | — | +| `handAna` | 163 816 | — | +| `drosteCata` | 164 824 | 1× | +| `drosteHylo` | 328 641 | 1× | +| `drosteAna` | 327 632 | 1× | +| `eoCata` | 361 386 | 2.2× | +| `eoHylo` | 361 386 | 1.1× | +| `eoAna` | 524 194 | 1.6× | + +The residual constant vs droste is the stack-safety machinery (per-node child array + +frames past depth 512) — droste's basic schemes are stack-*unsafe* naive recursion, and +the hand baselines are the irreducible floor. The zoo, grafting, fusion, and M-path +numbers follow. + +### The zoo — para / apo / histo / futu, grafting, fusion, and the M path + +The same `SchemesBench` workload (depth-12 perfect binary tree, 8 191 nodes) through the +decorated schemes — eo's typed zoo (`para` / `apo` / `histo` / `futu`) against +`droste.scheme.zoo` — plus the routes that pin the driver's design decisions: the monadic +machine at `cats.Id` and the fused `cross` against the materialising manual pair. B/op only: +it is the gate metric, and ns/op on a shared runner is advisory (see **Reproducing**). + +| Method | B/op | B/op vs droste | +|---|--:|--:| +| `eoPara` | 557 947 | 0.50× | +| `drostePara` | 1 114 890 | 1× | +| `eoApo` | 655 250 | 0.68× | +| `drosteApo` | 969 860 | 1× | +| `eoApoGraft` | 280 | 1.17× | +| `drosteApoGraft` | 240 | 1× | +| `eoHisto` | 557 970 | 1.54× | +| `drosteHisto` | 361 409 | 1× | +| `eoFutu` | 655 250 | 1.25× | +| `drosteFutu` | 524 161 | 1× | +| `eoCata` | 361 386 | 2.19× | +| `drosteCata` | 164 824 | 1× | +| `eoHylo` | 361 386 | — | +| `eoHyloM` | 820 299 | — | +| `eoRefoldCross` | 361 387 | — | +| `eoRefoldManual` | 885 589 | — | + +Zoo rows were re-pinned at the merge candidate on temurin@21; `eoRefoldCross` measures the fused +`cross` overload rather than the materialising spelling the 2026-06-12/13 sweep recorded, and the +droste zoo rows were re-measured (the third-party baseline's boxing-heavy `histo` / `futu` paths are +JVM-sensitive). The eo rows other than `eoRefoldCross` reproduce that sweep to within rounding. + +Five results: +- **`para` halves droste's allocation; `apo` comes in at ~0.7×.** eo decorates on the same array + machine as `cata`/`ana`, pairing each child's original subterm with its folded result off the + layer the machine already expanded — no per-node re-`project`, no per-node `List`. droste's zoo + re-embeds each subterm (para) and re-allocates the `Either` spine (apo), landing at 1 114 890 + vs 557 947, and 969 860 vs 655 250 B/op. +- **Grafting is O(1) on both — parity, with a guarantee.** The graft bench embeds a prebuilt + 8 191-node subtree in one `apo` step: both land flat at a couple hundred B/op (280 vs 240), + because droste's `zoo.apo` `R` *is* the fixed point, so its `Left(fix)` also embeds by + reference. eo's differentiator here is not speed but the **law-shaped `eq` guarantee** that + the grafted subtree is embedded untouched; the O(graft) re-walk contrast applies to generic + `distApo`-style decoration routes, not to droste's native `zoo.apo`. +- **`histo` / `futu` trail droste by ~1.3–1.5× B/op — the price of stack-safety.** The remaining + gap is the stack-safe machine's per-node child array; droste's zoo recursion is naive + call-stack recursion (stack-*unsafe*), so it pays no machine bookkeeping — and overflows on + the deep inputs eo's machine clears. +- **`eoHyloM` is the tailRecM per-event floor.** The monadic machine at `cats.Id` costs + 820 299 B/op vs 361 386 for `hylo` (~2.3×) — that delta is the `tailRecM` step-event + wrapping, the price of arbitrary-monad algebras. Two optimisation rounds got here: + 1 606 586 → 929 472 (leaf-inline combine + merged events + sentinel op encoding) → + 820 298 B/op (run 27445302118, 2026-06-13: typed `bubbled` continuation replacing the + per-leaf casting closure) — a cumulative **−49%**. +- **`ana.cross(cata)` fuses — no intermediate `S`.** The citizens carry fused `cross` overloads + (`Ana.cross(cata): Hylo`, `Ana.cross(histo): Dyna`, `Futu.cross(histo): Chrono`, + `Futu.cross(cata): Codyna`), so the refold spelling builds no intermediate tree: `eoRefoldCross` + is byte-identical to `hylo` (361 387 vs 361 386 B/op). The materialising spelling survives as the + hand-written `eoRefoldManual` `cata.get(ana.reverseGet(…))` (885 589 B/op) — the deliberate + contrast the table keeps. + ## Reproducing The integration tables are produced by the **Benchmarks** CI workflow diff --git a/site/docs/schemes.md b/site/docs/schemes.md index 95dad240..923d5928 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -1,239 +1,330 @@ # 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 through `PSVec` (a `Array[AnyRef]`-backed vector): this is -> very performant but **type-unsafe** — the per-node child results are erased to `AnyRef` and 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. -> -> The surface is deliberately small: `cata` / `ana` / `hylo` plus the `Coalg` alias, and nothing -> else — do not search this artifact for `para`, `apo`, `histo`, `futu`, or a monadic `cataM` -> family. You rarely miss `para`: the `cata` algebra `(S, PSVec[A]) => A` is already -> paramorphism-flavored (it sees the original node alongside its folded children). The wider zoo -> is *planned*, not shipped; until it lands, express recursive computations through these three -> rather than hand-rolling your own recursion. - -`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: +`cats-eo-schemes` expresses the recursion schemes **as optics** over a user-supplied +**pattern functor**, so they compose with the rest of the optic algebra rather than +living in a separate world — and the algebras pattern-match your functor's **named +constructors** (compile-time arity safety, no positional indexing): | Scheme | Optic | Direction | |--------|-------|-----------| -| `cata` | `Getter[S, A]` (driven by `Plated[S]`) | fold an existing `S` to an `A` | +| `cata` | `Getter[S, A]` | 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 | +| `para` / `apo` / `histo` / `futu` | the zoo (below) | decorated folds / unfolds | +| `cataM` / `anaM` / `hyloM` | `.get`/`.reverseGet` yield `M[…]` | effectful steps in a `Monad[M]` | -Task routing: fold an existing tree → `cata`; build one from a seed → `ana`; unfold-then-fold -without keeping the tree → `hylo`; rewrite a tree *in place* (`S => S`) → **not this module** — -that is `Plated.transform` / `Plated.rewrite` in core. - -> **Coming from droste / Matryoshka?** There is no fixpoint wrapper and no pattern functor -> here — do not look for `Fix` / `Mu` / `Nu`, a `Functor` instance for a base functor, or a -> `Basis`. The recursive type `S` is used directly, and `Plated[S]` is the one instance the -> fold side needs: - -| droste / Matryoshka | `cats-eo-schemes` | -|---------------------|-------------------| -| `Fix[F]` / `Mu[F]` / `Nu[F]` | the recursive type `S` itself — no wrapper | -| pattern functor `F[A]` | `PSVec[A]` — the (untyped) children vector | -| `Basis[F, S]` / `Recursive` + `Corecursive` | `Plated[S]` | -| `Algebra[F, A]` — `F[A] => A` | `(S, PSVec[A]) => A` — node plus its folded children | -| `Coalgebra[F, A]` | `Coalg[Seed, S]` — `Seed => (PSVec[Seed], PSVec[S] => S)` | -| `scheme.cata(alg)` | `Schemes.cata(alg)` — **is** a `Getter[S, A]` | -| `scheme.ana(coalg)` | `Schemes.ana(coalg)` — **is** a `Review[S, Seed]` | -| `scheme.hylo(alg, coalg)` | `Schemes.hylo(expand, alg)` — a **fused** `Getter[Seed, A]` | - -All three run on one stack-safe engine, so do **not** wrap your algebras in a trampoline or -`cats.Eval` layer of your own — that only adds allocation on top of a machine that is already -safe. The engine recurses directly on the JVM stack while shallower than 512 frames (balanced -trees never leave the fast path), then hands each deeper subtree to a heap `ArrayDeque` -machine; depths a hand-written recursion would overflow are fine, and a *non-terminating* -coalgebra fails by exhausting the heap (`OutOfMemoryError`), not by `StackOverflowError`. - -The examples below use the circe `Plated[Json]` from `cats-eo-circe` as a concrete recursive -`S` — but that is only an example convenience. `Plated[S]` is the **entire** requirement of the -fold side, and any recursive type gets one: derive it with -[`generics.plate[S]`](generics.md#plate-s-recursive-self-traversal-plated), hand-write it via -`Plated.fromChildrenVec`, or call `.asPlated` on an already-built self-traversal optic. There -is no `Basis`-style auto-derivation to look for — that one instance is all the machinery. +Everything runs on one stack-safe, post-order machine family (heap-stacked past depth +512, not JVM-call-stacked) — safe to depths a hand-written recursion would overflow, +tested at 10⁶. ```scala mdoc:silent 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 pattern-functor setup — `cata` / `ana` / `hylo` -The algebra sees each node plus its already-folded children. Here, sum every number anywhere -in a JSON document: +You supply a *pattern functor* `F[_]` — your recursive type with its recursive positions +replaced by a type parameter — and the algebra pattern-matches `F`'s **named +constructors**. -```scala mdoc:silent -val sumNumbers: Getter[Json, Int] = - Schemes.cata[Json, Int]((node, folded) => - node.asNumber.flatMap(_.toInt).getOrElse(0) + folded.toList.sum - ) +You write three things: the functor `F`, its `cats.Traverse`, and a `Basis` (`Project[F, S]` = +`project: S => F[S]`, plus `Embed[F, S]` = `embed: F[S] => S`). Everything else is derived from those. -val doc = Json.obj( - "a" -> Json.fromInt(1), - "b" -> Json.arr(Json.fromInt(2), Json.fromInt(3)), - "c" -> Json.fromString("ignored"), +```scala mdoc:silent +import cats.{Applicative, Eval, Traverse} +import dev.constructive.eo.schemes.Basis + +// A binary tree… +enum Bin: + case Leaf(n: Int) + case Branch(l: Bin, r: Bin) + +// …and its pattern functor: recursion (`Bin`) becomes the parameter `A`. +enum BinF[+A]: + case LeafF(n: Int) + case BranchF(l: A, r: A) + +given Traverse[BinF] with + def traverse[G[_]: Applicative, A, B](fa: BinF[A])(f: A => G[B]): G[BinF[B]] = + fa match + case BinF.LeafF(n) => Applicative[G].pure(BinF.LeafF(n)) + case BinF.BranchF(l, r) => Applicative[G].map2(f(l), f(r))(BinF.BranchF(_, _)) + def foldLeft[A, B](fa: BinF[A], b: B)(f: (B, A) => B): B = fa match + case BinF.LeafF(_) => b + case BinF.BranchF(l, r) => f(f(b, l), r) + def foldRight[A, B](fa: BinF[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = fa match + case BinF.LeafF(_) => lb + case BinF.BranchF(l, r) => f(l, Eval.defer(f(r, lb))) + +given Basis[BinF, Bin] = Basis( + { case Bin.Leaf(n) => BinF.LeafF(n); case Bin.Branch(l, r) => BinF.BranchF(l, r) }, + { case BinF.LeafF(n) => Bin.Leaf(n); case BinF.BranchF(l, r) => Bin.Branch(l, r) }, ) + +val binTree: Bin = Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) +``` + +`cata` folds an `S` to an `A`. The algebra sees the node plus its already-folded children **as a +typed `BinF[A]`** — `l` and `r` are `A`, by name, no positional indexing: + +```scala mdoc:silent +val sumLeavesF = Schemes.cata[BinF, Bin, Int] { + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + } ``` ```scala mdoc -sumNumbers.get(doc) +sumLeavesF.get(binTree) ``` -Because `cata` is a `Getter`, it composes onto any optic that ends in the recursive type: +`ana` builds an `S` from a seed via a single fused coalgebra `Seed => F[Seed]`; `Embed` glues each +layer. `hylo` is the **fused** refold (`Seed => A`, no intermediate `Bin`) and needs only +`Traverse[F]`: ```scala mdoc:silent -final case class Payload(label: String, body: Json) -val bodySum: Getter[Payload, Int] = - Getter[Payload, Json](_.body).andThen(sumNumbers) +// build a right spine of (n+1) unit leaves +val buildBin = Schemes.ana[BinF, Int, Bin] { n => + if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1) +} + +// fused: count the leaves directly, building no Bin +val countLeavesF = Schemes.hylo[BinF, Int, Int]( + coalg = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1), + alg = { + case BinF.LeafF(_) => 1 + case BinF.BranchF(l, r) => l + r + }, + ) ``` ```scala mdoc -bodySum.get(Payload("p", doc)) +sumLeavesF.get(buildBin.reverseGet(3)) // 4 unit leaves +countLeavesF.get(3) // same count, fused — no Bin materialised +countLeavesF.get(1000000) // stack-safe: the heap machine, O(depth) heap ``` -### 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]`](optics.md#unfold) — 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: +`cata` and `hylo` are **Getter-shaped** (forward reads over the `Direct` carrier) and `ana` is +**Review-shaped** (its build-only dual), so they compose with the rest of the optic algebra: `cata`/`hylo` via `andThen`, and the +build⇄read refold via `ana.cross(cata)` (the materializing `ana(…).cross(cata(…))` equals the fused +`hylo` for a pure algebra — the hylo law). They run on +a **`< 512`-on-stack / heap-`ArrayDeque` machine** (no `cats.Eval` +trampoline) — your `Traverse[F]` is used only per *layer* (any lawful instance works), so they are +stack-safe to depths a hand-written recursion would overflow and allocate close to droste (see the +[benchmarks](benchmarks.md)). The typed path is the only path — the earlier untyped +`Plated`-driven spelling was removed once this one subsumed it (see the note at the bottom of this +page). Deriving `Project`/`Embed` from the `S`↔`F` correspondence is future work; today they are +hand-written (as above). + +### Composing with lenses + +Because the schemes read through `.get`, they slot into a lens pipeline. Compose a **lens chain** +to focus a recursive field buried in a record, then fold it with the scheme — wrap the composite +read in a `Getter` so it stays a reusable optic (the same composed lens still *writes* the field +back): ```scala mdoc:silent -import dev.constructive.eo.optics.{Review, Unfold} +import dev.constructive.eo.optics.Lens -// node-blind algebra (counts nodes) carried as a build-only optic … -val sizeAlg = Unfold.algebra[Int, Int, PSVec](kids => 1 + kids.toList.sum) +case class Inner(label: String, tree: Bin) +case class Doc(id: Int, inner: Inner) -// … and a per-layer post-processing step composed in front of it -val weighted = Review[Int, Int](_ * 2).andThen(sizeAlg) +val innerL = Lens[Doc, Inner](_.inner, (d, i) => d.copy(inner = i)) +val treeL = Lens[Inner, Bin](_.tree, (i, t) => i.copy(tree = t)) +val deepTree = innerL.andThen(treeL) // Lens[Doc, Bin] — lens composition -val docSize: Getter[Json, Int] = Schemes.cata[Json, Int](sizeAlg) +// read through the composed lens, fold with the scheme, wrap as a Getter → reusable optic +val docLeafSum = Getter[Doc, Int](doc => sumLeavesF.get(deepTree.get(doc))) + +val record = Doc(1, Inner("x", binTree)) ``` ```scala mdoc -docSize.get(doc) +docLeafSum.get(record) // focus Doc -> its tree, then fold to the leaf sum +deepTree.replace(Bin.Leaf(0))(record) // the SAME composed lens writes the field back ``` -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. +The single peel/glue layer is also available on its own as `Schemes.fLayer[F, S]`, an +`Optic[S, S, S, S, MultiFocus[F]]` (`to = project`, `from = embed`) — the typed analogue of +`Plated`'s `plate` for one layer. It composes with the rest of core on the shared carrier: read a +node's immediate foci via `.foldMap` (`Foldable[F]`), rewrite them via `.modify`/`.replace` +(`Functor[F]`), or effect over them via `.modifyA`/`.all` (`Traverse[F]`). It is one layer, not the +recursion; the `Plated.fromBasis` derivation is its recursive face, and the recursive schemes drive +`project`/`embed` themselves rather than composing `fLayer`. + +## The zoo: para / apo / histo / futu + +The decorated schemes are **one sum/product symmetry**, shipped as named optic citizens — +`final class`es in `zoo` carrying their parts, so composition and fusion (`ana.cross(cata)`) +resolve against the concrete types. Their decorations are consumed natively by the engine on +`Affine`'s arms worn on the build seam (see below): + +| scheme | decoration | shape | +|---|---|---| +| cata / ana | none (`X = Nothing` / `S`) | the forgetful base | +| **para** | child slots carry the original subterms | product | +| **apo** | child slots may graft a finished subtree | sum | +| **histo** | full decorated history per child (`Attr`) | iterated product | +| **futu** | multiple layers per step (`Coattr`) | iterated sum | +| zygo / mutu / cozygo / comutu / dyna / chrono / elgot / … | auxiliary carriers between the towers | see the scaladocs | + +`para` pairs each child slot with its **original subterm** — taken from the nodes the machine +already walks, with no per-node re-`embed`: + +```scala mdoc:silent +// count branches whose left child is a leaf — needs the subterm, not just the result +val leftLeafBranches = Schemes.para[BinF, Bin, Int] { + case BinF.LeafF(_) => 0 + case BinF.BranchF((ls, l), (_, r)) => + l + r + (ls match { case Bin.Leaf(_) => 1; case _ => 0 }) +} +``` -## `ana` — an unfold that is a `Review` +```scala mdoc +leftLeafBranches.get(binTree) +``` -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: +`apo` lets the coalgebra answer any slot with an **already-finished subtree** — grafted into the +result **by reference**, never recursed, never projected (the law suite pins this with an `eq` +check, so the O(1) claim survives any benchmark noise): ```scala mdoc:silent -// 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))) - } +val cached: Bin = binTree // an expensive subtree you already have + +val patched = Schemes.apo[BinF, Int, Bin] { n => + if n <= 1 then BinF.LeafF(9) + else BinF.BranchF(Left(cached), Right(n - 1)) // graft left, keep unfolding right +} ``` ```scala mdoc -buildTree.reverseGet(2) +patched.reverseGet(2) ``` -## `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: +`histo` gives the algebra each child's **entire decorated history** (`Attr[F, A]`: the result +plus that child's own decorated layer — course-of-value recursion; note it inherently retains +O(n) `Attr` cells): ```scala mdoc:silent -// 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, - ) +import dev.constructive.eo.schemes.zoo.{Attr, Coattr} + +// add each branch's grandchildren-through-history to its result +val withGrand = Schemes.histo[BinF, Bin, Int] { + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => + def grand(a: Attr[BinF, Int]): Int = a.tail match + case BinF.LeafF(_) => 0 + case BinF.BranchF(gl, gr) => gl.head + gr.head + l.head + r.head + grand(l) + grand(r) +} ``` -```scala mdoc -countLeaves.get(2) -countLeaves.get(20) // stack-safe; no 2^20-node tree is materialised +`futu` lets the coalgebra emit **several layers per step** (`Coattr.Roll` layers are unrolled +with no further coalgebra calls): + +```scala mdoc:silent +val twoAtATime = Schemes.futu[BinF, Int, Bin] { n => + if n <= 1 then BinF.LeafF(1) + else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) +} ``` -## `cross` — build, then read (structure-preserving) +### Composition and fusion: `cross` vs `hylo` -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`): +`ana` is a build-only `Review` and `cata` a read-only `Getter` — duals over `Direct`. The +unfold-then-fold refold is their `cross` at the build-output⇄read-input seam (exactly what +`Optic.cross` documents: "the motivating case is `ana.cross(cata)`"), yielding a forward read. +`ana(…).cross(cata(…))` is the **materializing** hylo: it builds the whole `S`, then folds it. +`Schemes.hylo` is the **fused** spelling — one single-pass machine, each node built once and folded +immediately, no intermediate `S` and no second traversal. The two agree for a pure algebra (the +hylo law). ```scala mdoc:silent -val refoldSum = buildTree.cross(sumNumbers) // Optic[Int, Unit, Int, Unit, Direct] +val zooExpand: Int => BinF[Int] = n => + if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) +val zooSum: BinF[Int] => Int = + { case BinF.LeafF(n) => n; case BinF.BranchF(l, r) => l + r } + +val fusedLeafSum = Schemes.ana[BinF, Int, Bin](zooExpand).cross(Schemes.cata(zooSum)) ``` ```scala mdoc -refoldSum.get(2) +fusedLeafSum.get(6) ``` -`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. +### A decorated fold with a helper: `zygo` -`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`: +The generality droste exposes as `gcata`/`gana` lives here as **named citizens between the +towers**. A zygomorphism — the main algebra consults an auxiliary algebra alongside each child's +result — is one constructor: the helper algebra, then the main algebra reading `(helper, main)` +per child. (`para` is exactly `zygo` at `B = S` with the helper `embed`; `mutu` generalises to +two mutually-recursive algebras.) ```scala mdoc:silent -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]) +val leafCount: BinF[Int] => Int = + { case BinF.LeafF(_) => 1; case BinF.BranchF(l, r) => l + r } + +// leaf sum, where every branch also sees its children's helper results +val sumWithCount = Schemes.zygo[BinF, Bin, Int, Int](leafCount) { + case BinF.LeafF(n) => n + case BinF.BranchF((cl, l), (cr, r)) => l + r + cl * cr +} ``` ```scala mdoc -sumBuilt.foldMap[Int](identity)(4) // 1+2+3+4 +sumWithCount.get(binTree) ``` -## Checking the hylo law — `cats-eo-schemes-laws` +The named citizens dispatch to native engine routes — the decoration is consumed inside the +machine, not as a per-node optic dispatch. -The hylo law above is not just prose: it ships as a Discipline law in its own published -artifact, so you can pin your coalgebra / algebra pairs against it in your test suite: +### Effectful steps: `cataM` / `anaM` / `hyloM` -```scala -libraryDependencies += "dev.constructive" %% "cats-eo-schemes-laws" % "@VERSION@" % Test +When producing a layer is itself effectful — fetching a node's children from a service, the +`arbo` Calculator shape — the M-generic drivers run the same machine **lifted through +`Monad[M].tailRecM`** (one `M`-action per node event; stack-safety rides on M's `tailRecM`; +supported Ms are single-pass and *linear* — a branching/replaying `M` like `List` is documented +unsupported). `cataM` reads via `.get: S => M[A]` and `anaM` builds via +`.reverseGet: Seed => M[S]`; `hyloM` is the **fused** effectful refold — one single-pass machine, +no intermediate `S` built (the materialising pair `cataM(alg).get(anaM(coalg).reverseGet(seed))` +agrees with it — the `M = Id` cross-architecture pin in the spec). + +```scala mdoc:silent +import cats.data.State + +type Counted[T] = State[Int, T] // counts service calls, arbo's GetSellOptions shape + +def fetchLayer(n: Int): Counted[BinF[Int]] = + State(calls => (calls + 1, zooExpand(n))) + +// fused: each node's layer is fetched in M and folded immediately — one pass, no Bin built +val countedLeafSum = Schemes.hyloM[Counted, BinF, Int, Int]( + fetchLayer, + fa => State.pure(zooSum(fa)), +) ``` -`dev.constructive.eo.schemes.laws.HyloLaws[Seed, S, A]` states the fusion contract: -`hylo(expand, fusedAlg).get(seed) == ana(coalg).cross(cata(alg)).get(seed)`, where the seed -expansion is *derived* from the coalgebra (`coalg(_)._1`) — so the only coherence an instance -asserts is that its `fusedAlg` corresponds to its `alg` over the nodes the coalgebra builds; -an incoherent pair fails the law, which is the point. The Discipline wrapper, -`dev.constructive.eo.schemes.laws.discipline.HyloTests`, is wired like the core `cats-eo-laws` -rule-sets: an abstract class whose `laws` member you override with a `HyloLaws` instance -supplying your `coalg`, `alg`, and `fusedAlg`, then `checkAll` its `hylo` rule-set. You bring -the `Arbitrary[Seed]` — the artifact ships no generators — and the seed generator should -straddle the engine's 512-frame on-stack depth limit so both the recursive fast path and the -heap machine are exercised under the equality. The comparison uses `equals`, so pick a result -type `A` with structural equality (any case class, enum, or primitive). +```scala mdoc +countedLeafSum.get(6).run(0) // (service calls, leaf sum) — one fused pass +``` -The artifact is separate from `cats-eo-laws` because its laws quantify over `schemes` types. -Hylo fusion is its only rule-set today; more scheme laws are expected to land there as the zoo -grows. +### The build-seam carrier is `Affine` + +The decoration machinery needs no new carrier: it rides **`Affine`** with its arms read on the +*build* seam — `Hit(context, focus)` keeps going; `Miss(payload)` means "this slot is already +finished — do not call the coalgebra" (apo grafts a finished subtree, futu unrolls a prebuilt +layer). The build-channel injection vocabulary is the `Graft[Affine]` instance (`done = Miss`, +`step = Hit`), and its laws are the graft-finality and round-trip equations in `cats-eo-laws`. +Affine's own composition row covers decoration composition: `Affine.assoc` (same-carrier +`andThen`) plus the cross-carrier bridges from `Tuple2` (Lens) and `Either` (Prism), so +`lens.andThen(apoScatter)`-style compositions resolve; `Schemes.apoScatter` exposes the +`Left(s) → Miss(s)` graft channel as a composable scatter optic. On the scheme side, +`elgot`/`coelgot` (the answer-level short-circuit and seed-reading refolds) are shipped citizens, +and `meta`/`metaChrono` complete the non-fusing fold→unfold quadrant. + +--- + +> An earlier `PSVec`-based untyped path (`cata`/`ana`/`hylo` driven by `Plated`) was +> removed once the typed path subsumed it: the erased positional indexing it required +> made algebra arity slips a runtime error, which is exactly what the typed path fixes. diff --git a/tests/src/test/scala/dev/constructive/eo/AffineBuildSeamSpec.scala b/tests/src/test/scala/dev/constructive/eo/AffineBuildSeamSpec.scala new file mode 100644 index 00000000..e5e81051 --- /dev/null +++ b/tests/src/test/scala/dev/constructive/eo/AffineBuildSeamSpec.scala @@ -0,0 +1,105 @@ +package dev.constructive.eo + +import org.specs2.mutable.Specification + +import data.Affine +import data.Affine.{Hit, Miss} +import optics.Optic + +/** Behaviour checks for [[Affine]] worn on its **build seam** — the graft-finality equations the + * recursion-scheme zoo's decoration citizens must satisfy, stated against a toy citizen here (the + * named `apo`/`futu` decorations in `cats-eo-schemes` state them per value). + * + * The toy citizen pins the existential the way every concrete decoration does: `X = (W, F[W])` + * with `Fst[X] = W` (the `Miss` payload is a finished result — `Graft.done`) and `Snd[X] = F[W]` + * (the one-layer leftover context carried by `Hit` — `Graft.step`). + */ +class AffineBuildSeamSpec extends Specification: + + private type TX = (Int, List[Int]) + + // Toy full citizen: W = Int, F = List. Negative values are "already finished" + // (Miss/done); non-negative ones keep going, carrying one layer of context (Hit/step). + private val toy: Optic[Int, Int, Int, Int, Affine] { type X = TX } = + new Optic[Int, Int, Int, Int, Affine]: + type X = TX + def to(w: Int): Affine[X, Int] = + if w < 0 then new Miss[X](w) + else new Hit[X, Int](List(w), w) + def from(xb: Affine[X, Int]): Int = xb match + case d: Miss[X] => d.fst + case s: Hit[X, Int] => s.b + + "Miss re-typing across a focus change is an allocation-free upcast" in { + val d: Miss[TX] = new Miss[TX](5) + val widened: Affine[TX, String] = d // Miss[A] :> Affine[A, Nothing] = Affine[A, String] + (widened.asInstanceOf[AnyRef] eq d.asInstanceOf[AnyRef]) === true + } + + "a full Affine build-seam citizen" should { + + "treat Miss as final: from(Miss(w)) == w" in { + (toy.from(new Miss[TX](-7)) === -7).and(toy.from(new Miss[TX](42)) === 42) + } + + "round-trip the Hit arm: from(to(w)) == w" in { + List(0, 1, 17, 4096).map(w => toy.from(toy.to(w))) === List(0, 1, 17, 4096) + } + + "round-trip the Miss arm: from(to(w)) == w on finished inputs" in { + List(-1, -100).map(w => toy.from(toy.to(w))) === List(-1, -100) + } + } + + "Affine.assoc — the composition-matrix row, exercised through the build seam" should { + + // A second citizen whose Miss fires on an *even* focus, so toy.andThen(innerToy) reaches all + // three composed arms: outer Miss (w<0), Hit∘Hit (w≥0 odd), Hit∘Miss (w≥0 even). + val innerToy: Optic[Int, Int, Int, Int, Affine] { type X = TX } = + new Optic[Int, Int, Int, Int, Affine]: + type X = TX + def to(w: Int): Affine[X, Int] = + if w % 2 == 0 then new Miss[X](w) else new Hit[X, Int](List(w), w) + def from(xb: Affine[X, Int]): Int = xb match + case d: Miss[X] => d.fst + case s: Hit[X, Int] => s.b + + val composed = toy.andThen(innerToy) + + "affine.andThen(affine) type-checks and round-trips across all three arms" in { + // w<0 → outer Miss; w≥0 odd → Hit∘Hit; w≥0 even → Hit∘Miss. + List(-5, 1, 3, 4, 16, 17).map(w => composed.from(composed.to(w))) === + List(-5, 1, 3, 4, 16, 17) + } + + "outer Miss short-circuits the composition (Left arm of Z)" in { + composed.from(composed.to(-9)) === -9 + } + } + + "Affine cross-carrier bridges" should { + + "Composer[Tuple2, Affine] lifts a Lens-shaped optic to always-Hit, round-tripping" in { + val tupleOptic: Optic[(Int, String), (Int, String), Int, Int, Tuple2] = + new Optic[(Int, String), (Int, String), Int, Int, Tuple2]: + type X = String + def to(s: (Int, String)): (X, Int) = (s._2, s._1) + def from(p: (X, Int)): (Int, String) = (p._2, p._1) + val af = tupleOptic.morph[Affine] + af.from(af.to((7, "x"))) === ((7, "x")) + } + + "Composer[Either, Affine] maps Right→Hit and Left→Miss, round-tripping both arms" in { + val eitherOptic: Optic[Option[Int], Option[Int], Int, Int, Either] = + new Optic[Option[Int], Option[Int], Int, Int, Either]: + type X = Unit + def to(s: Option[Int]): Either[X, Int] = s match + case Some(n) => Right(n) + case None => Left(()) + def from(xb: Either[X, Int]): Option[Int] = xb match + case Right(n) => Some(n) + case Left(_) => None + val af = eitherOptic.morph[Affine] + (af.from(af.to(Some(5))) === Some(5)).and(af.from(af.to(None)) === None) + } + } diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala index 815b5dc8..18de0275 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala @@ -310,6 +310,11 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: .affine, ) + // ----- Affine build-seam laws ------------------------------------ + // The decoration vocabulary of the recursion-scheme zoo lives on Affine's + // arms (Miss = finished, Hit = keep going — see Graft[Affine]); the + // graft-channel coherences ride the same AffineTests rule set above. + // ----- ModifyF carrier laws ------------------------------------- checkAll( diff --git a/tests/src/test/scala/dev/constructive/eo/PlatedConcurrencySpec.scala b/tests/src/test/scala/dev/constructive/eo/PlatedConcurrencySpec.scala new file mode 100644 index 00000000..99a19b77 --- /dev/null +++ b/tests/src/test/scala/dev/constructive/eo/PlatedConcurrencySpec.scala @@ -0,0 +1,70 @@ +package dev.constructive.eo + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.Duration +import scala.concurrent.{Await, Future} + +import dev.constructive.eo.optics.Plated +import org.specs2.mutable.Specification + +import PlatedFixtures.given + +/** Concurrency spec for [[Plated]] operations. + * + * [[Plated.transform]], [[Plated.universe]], and [[Plated.rewrite]] build no shared mutable state + * across invocations — each call to the underlying machine is independent. These tests exercise + * that claim with N=16 concurrent tasks running `transform`, `universe`, and `rewrite` over a + * shared prebuilt structure, asserting all results correct and deterministic. + */ +class PlatedConcurrencySpec extends Specification: + + private val N = 16 + + // Shared prebuilt tree — used from all N concurrent tasks. + private val sharedBin: Bin = + Bin.Node(Bin.Node(Bin.Leaf(1), Bin.Leaf(2)), Bin.Node(Bin.Leaf(3), Bin.Leaf(4))) + + // Expected results for each operation on sharedBin. + // universe count: 7 nodes total (4 leaves + 3 nodes, root included). + private val ExpectedUniverseSize = 7 + + // After incrementing every leaf by 1 the leaf sum becomes 1+1 + 2+1 + 3+1 + 4+1 = 14. + private val incLeaf: Bin => Bin = { + case Bin.Leaf(v) => Bin.Leaf(v + 1) + case node => node + } + + private def leafSum(b: Bin): Int = b match + case Bin.Leaf(v) => v + case Bin.Node(l, r) => leafSum(l) + leafSum(r) + + private val ExpectedTransformedSum = 14 + + // rewrite: fold adjacent Node(Leaf, Leaf) → single Leaf sum. + // sharedBin → Node(Leaf(3), Leaf(7)) → Leaf(10). + private val foldAdjacent: Bin => Option[Bin] = { + case Bin.Node(Bin.Leaf(a), Bin.Leaf(b)) => Some(Bin.Leaf(a + b)) + case _ => None + } + + "N=16 concurrent Plated.transform + universe + rewrite on a shared structure all return correct results" >> { + val tasks: Seq[Future[Boolean]] = (1 to N).map { _ => + Future { + // (a) transform: increment every leaf + val transformed = Plated.transform(incLeaf)(sharedBin) + val transformOk = leafSum(transformed) == ExpectedTransformedSum + + // (b) universe: count all nodes + val universeOk = Plated.universe(sharedBin).length == ExpectedUniverseSize + + // (c) rewrite: fold adjacent leaves to a fixpoint + val rewrote = Plated.rewrite(foldAdjacent)(sharedBin) + val rewriteOk = rewrote == Bin.Leaf(10) + + transformOk && universeOk && rewriteOk + } + } + + val results = Await.result(Future.sequence(tasks), Duration.Inf) + results.forall(identity) must beTrue + }