From ccc42e50b5effdd2e679dce2ec859a9d4f3536a0 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Tue, 9 Jun 2026 19:19:07 +0200 Subject: [PATCH 01/61] feat(schemes): typed pattern-functor recursion schemes (cataF/anaF/hyloF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in, type-safe complement to #23's PSVec-driven Schemes.cata/ana/hylo. The user supplies a pattern functor F[_] + Traverse[F] and hand-writes a Basis (Project[F,S] / Embed[F,S]); algebras then pattern-match F's NAMED constructors instead of indexing an erased PSVec[AnyRef]. - Basis.scala: Project/Embed (+ combined Basis) type classes with coherence laws. - Schemes.{cataF,anaF,hyloF,fLayer}: a cats.Eval trampoline over Traverse[F] (the Plated.rewrite precedent / droste's stack-safe hyloM shape). cataF/hyloF return DirectGetter, anaF returns Review — Direct-carried, so they compose via andThen/cross with NO new core carrier instances. fLayer realizes the project/ embed layer as Optic[S,S,S,S,Forget[F]] (no Optic trait change). - Empirically stack-safe to 10^6 for all three (anaF the OOM frontier), plus a wide-and-deep RoseF; pure hylo law + Project/Embed coherence (BinF and RoseF) under ScalaCheck; cataF cross-checked against #23's Plated cata. #23 untouched. - mdoc docs section + top-of-page caveat pointing at the typed path. The Eval trampoline is O(depth) but allocation-heavy, so the module forks its tests with -Xmx2g (the 10^6 typed + #23 cases together OOM sbt's in-process heap). Deferred follow-ups: JMH B/op vs droste basic (Eval-vs-heap driver decision); deriving Project/Embed from the S<->F correspondence; a pure F[A]=>A overload. Plan: docs/plans/2026-06-09-002-feat-typed-recursion-schemes-plan.md Co-Authored-By: Claude Opus 4.8 (1M context) --- build.sbt | 7 + ...9-002-feat-typed-recursion-schemes-plan.md | 549 ++++++++++++++++++ .../dev/constructive/eo/schemes/Basis.scala | 54 ++ .../dev/constructive/eo/schemes/Schemes.scala | 91 ++- .../eo/schemes/SchemesFLawsSpec.scala | 126 ++++ .../eo/schemes/SchemesFSpec.scala | 226 +++++++ .../eo/schemes/samples/PatternFunctors.scala | 79 +++ site/docs/schemes.md | 109 +++- 8 files changed, 1237 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2026-06-09-002-feat-typed-recursion-schemes-plan.md create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/samples/PatternFunctors.scala diff --git a/build.sbt b/build.sbt index f3c8e3c7..f60932de 100644 --- a/build.sbt +++ b/build.sbt @@ -663,6 +663,13 @@ lazy val schemes: Project = project libraryDependencies += cats, libraryDependencies += discipline % Test, libraryDependencies += scalacheck % Test, + // Fork the tests into a fresh JVM with a generous heap. The typed schemes' `Eval` trampoline is + // O(depth) but allocation-heavy (several `Eval` nodes per layer), so the 10^6-deep stack-safety + // cases need ~1 GB; running them alongside #23's 10^6 cases in sbt's in-process heap OOMs. + // Forking isolates them and makes `sbt test` deterministic. (The deferred JMH bench quantifies + // the allocation; the explicit-heap-machine fallback would shrink it.) + Test / fork := true, + Test / javaOptions += "-Xmx2g", ) // Discipline-style laws for the recursion-scheme module. Lives outside 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..ee389633 --- /dev/null +++ b/docs/plans/2026-06-09-002-feat-typed-recursion-schemes-plan.md @@ -0,0 +1,549 @@ +--- +title: "feat: Typed pattern-functor recursion schemes (cataF/anaF/hyloF)" +type: feat +status: active +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]`). + **Subtlety the tests must guard:** stack-safety holds only when `Traverse[F].traverse` sequences a + node's children through the supplied `Eval` `Applicative` **without on-stack recursion across the + spine** — true for cats-derived instances and bounded-fanout hand-written ones, but a naively + recursive hand-written `Traverse[F]` (or a non-`Eval`-lazy `foldRight`) can reintroduce stack + growth that a binary-spine depth test won't catch. Recommend users *derive* `Traverse[F]` where + possible; U4 adds a wide-and-deep shape to exercise the orthogonal failure mode. +- **Driver mechanism: `Eval` trampoline first (primary), explicit typed heap machine as a deferred, + B/op-gated fallback.** This reconciles the apparent tension with #23's "no `Eval`" choice: #23 had + `Plated` hand it a *flat* `PSVec[S]` of children, making an explicit `ArrayDeque` machine natural + and `Eval` unnecessary. The typed path threads a *generic* `F`, where `Traverse[F] + Eval` (the + `Plated.rewrite` precedent, and droste's `hyloM` shape) is the clean stack-safe primitive. + Different child representation → different best tool; `Eval` here is principled, not a regression + of #23's reasoning. If U6's B/op measurement shows `Eval` misses droste-basic parity, the + fallback is an explicit typed heap machine (Foldable-extract into a buffer, fold, `Traverse`-rebuild + with an index) — more code, must not re-erase `F`'s children to `AnyRef`. This is a perf + optimization, **not** v1-blocking. +- **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]`. + +### Deferred to Implementation + +- **Eval vs explicit heap machine (origin R2, B/op-gated):** Ship the `Eval` driver; measure B/op vs + droste basic in U6; build the heap-machine fallback only if `Eval` misses parity. Decided on B/op, + not local ns. +- **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. + +- [ ] **Unit 6: JMH benchmark — typed-F vs droste basic (optional / splittable)** + +**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` 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..5e90c73b --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala @@ -0,0 +1,54 @@ +package dev.constructive.eo +package schemes + +/** The user-supplied bridge between a recursive type `S` and one *layer* of its **pattern functor** + * `F[_]`, for the typed recursion-scheme path ([[Schemes.cataF]] / [[Schemes.anaF]] / + * [[Schemes.hyloF]]). + * + * 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`). + * The 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 `PSVec[AnyRef]` — + * the type-safety the default [[Schemes.cata]] path lacks. + * + * Unlike the `F` type itself (which the user must write — Scala-3 macros emit terms, not type + * definitions), `Project`/`Embed` are ordinary instances. v1 expects them **hand-written** + * (droste's model); deriving them from the `S`↔`F` constructor correspondence is deferred future + * work. + * + * '''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) 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..57d25d7b 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -5,8 +5,10 @@ import scala.annotation.tailrec import java.util.ArrayDeque -import data.PSVec -import optics.{Getter, Plated, Review, Unfold} +import cats.{Eval, Traverse} + +import data.{Forget, PSVec} +import optics.{Getter, Optic, Plated, Review, Unfold} /** Recursion schemes as composable optics, built on the core optic surface. * @@ -236,3 +238,88 @@ object Schemes: // 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)))) + + // =========================================================================================== + // Typed pattern-functor path — the opt-in, type-safe complement to the PSVec schemes above. + // + // The user supplies a pattern functor `F[_]` + its `Traverse[F]`, and (for cata/ana) + // `Project[F, S]` / `Embed[F, S]`. Algebras then pattern-match `F`'s NAMED constructors + // (`case BranchF(l, r) => l + r`) — no `PSVec[AnyRef]`, no positional indexing. See [[Basis]]. + // + // Stack-safety here comes from a `cats.Eval` trampoline over `Traverse[F]` (the `Plated.rewrite` + // precedent, and droste's stack-safe `hyloM` shape), NOT the `ArrayDeque` machine above: with a + // generic `F` the children are a typed `F[child]`, and `Traverse[F] + Eval.defer` is the lawful + // stack-safe primitive. Each recursive descent is guarded by `Eval.defer`, so forcing `.value` + // trampolines on the heap (O(depth) space) instead of growing the JVM call stack. The supplied + // `Traverse[F]` must sequence a node's children through the `Eval` applicative WITHOUT on-stack + // recursion (true for cats-derived and bounded-fanout instances) — prefer deriving it. + // =========================================================================================== + + /** The single *layer* optic for a pattern functor `F`: `project`/`embed` worn as the existing + * [[dev.constructive.eo.data.Forget]] carrier. `to = project: S => F[S]`, `from = embed: F[S] => + * S`, so it is a genuine `Optic[S, S, S, S, Forget[F]]` with **no change to the `Optic` trait**. + * + * It is a single-layer *peel/glue* (like `Plated`'s `plate`, but one layer, not the recursion). + * The recursive schemes below drive `to`/`from` themselves and return `Direct`-carried optics, + * so `fLayer` is mainly the concrete proof that a typed `F` is an optic carrier, plus an + * observational read: given `Foldable[F]` it reads its layer's foci via `.foldMap`. Note it does + * NOT compose as freely as `plate` (a `Traversal`): same-carrier `andThen` over `Forget[F]` + * needs `Monad[F]`, which most pattern functors are not — so `fLayer` is a one-layer lens on the + * structure, not a composable traversal. + */ + def fLayer[F[_], S](using P: Project[F, S], E: Embed[F, S]): Optic[S, S, S, S, Forget[F]] = + new Optic[S, S, S, S, Forget[F]]: + type X = Any + val to: S => Forget[F][X, S] = s => P.project(s) + val from: Forget[F][X, S] => S = fs => E.embed(fs) + + /** Catamorphism over a typed pattern functor `F`, as a composable `Getter`. `alg` sees the + * original node `S` (paramorphism-flavored) plus its already-folded children as a typed `F[A]`. + * Pure `F[A] => A` folds ignore the `S`. Stack-safe (`Eval` trampoline) to arbitrary depth in + * O(depth) heap. Requires `Project[F, S]` (to peel each layer) and `Traverse[F]` (to fold it). + * + * @note + * Stack-safety requires a `Traverse[F]` that sequences children through the `Eval` applicative + * without on-stack recursion (cats-derived and bounded-fanout hand-written instances qualify; + * an `Eval`-based `foldRight` is the key) — see the section comment above. + */ + def cataF[F[_], S, A]( + alg: (S, F[A]) => A + )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = + def go(s: S): Eval[A] = + F.traverse(P.project(s))(child => Eval.defer(go(child))).map(folded => alg(s, folded)) + Getter[S, A](s => go(s).value) + + /** Anamorphism over a typed pattern functor `F`, as a `Review`. The single fused coalgebra `Seed + * => F[Seed]` yields one typed layer of child seeds; [[Embed]] assembles each layer into the + * built `S`. Materializing — the built `S` is O(nodes). Stack-safe (`Eval` trampoline), and + * because it also holds the materialized `S`, it is the heaviest of the three on heap. Requires + * `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input before output) to match + * [[hyloF]] and the `PSVec` [[ana]]. + * + * @note Stack-safety requires an `Eval`-sequencing `Traverse[F]` (see the section comment above). + */ + def anaF[F[_], Seed, S]( + coalg: Seed => F[Seed] + )(using F: Traverse[F], E: Embed[F, S]): Review[S, Seed] = + def go(seed: Seed): Eval[S] = + F.traverse(coalg(seed))(child => Eval.defer(go(child))).map(E.embed) + Review[S, Seed](seed => go(seed).value) + + /** Hylomorphism over a typed pattern functor `F` — the **fused** refold `Seed => A`, building + * **no intermediate `S`** (so it needs neither `Project` nor `Embed`, only `Traverse[F]`). + * `coalg` unfolds a seed into one typed layer; `alg` folds the layer's results to `A` (the seed + * is supplied, paramorphism-flavored). Stack-safe (`Eval` trampoline). Equal to + * `anaF(coalg).cross(cataF(alg))` for a *pure* algebra (the hylo law); for a node-reading para + * algebra the two agree only under the seed↔`embed(coalg(seed))` correspondence. + * + * @note + * Stack-safety requires an `Eval`-sequencing `Traverse[F]` (see the section comment above). + */ + def hyloF[F[_], Seed, A]( + coalg: Seed => F[Seed], + alg: (Seed, F[A]) => A, + )(using F: Traverse[F]): Getter[Seed, A] = + def go(seed: Seed): Eval[A] = + F.traverse(coalg(seed))(child => Eval.defer(go(child))).map(folded => alg(seed, folded)) + Getter[Seed, A](seed => go(seed).value) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala new file mode 100644 index 00000000..2ddbc540 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala @@ -0,0 +1,126 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import org.scalacheck.Prop.forAll +import org.scalacheck.{Arbitrary, Gen} +import org.specs2.ScalaCheck +import org.specs2.mutable.Specification + +import optics.Optic.* // get, cross + +import schemes.samples.{Bin, BinF, Rose, RoseF} + +/** Law/coherence checks for the typed pattern-functor schemes. + * + * - '''Project/Embed coherence''' — the hand-written `S`↔`F` correspondence is not + * compiler-checked, so its two round-trip laws are property-tested here. + * - '''Hylo law (pure flavor)''' — `hyloF == anaF.cross(cataF)` holds *generically* only when + * the algebra ignores its node argument (a pure `F[A] => A` fold). Tested via `forAll`. + * - '''Hylo law (para flavor)''' — for a node-reading algebra, `hyloF` threads the *seed* while + * the materializing `cataF` threads the rebuilt `S`, so the two coincide only under the + * seed↔`embed(coalg(seed))` correspondence. Verified at specific points, NOT via `forAll`. + */ +class SchemesFLawsSpec extends Specification with ScalaCheck: + + // bounded-depth Bin generator (keeps trees small for the property runs) + private def genBin(depth: Int): Gen[Bin] = + if depth <= 0 then Gen.choose(0, 20).map(Bin.Leaf(_)) + else + Gen.frequency( + 1 -> Gen.choose(0, 20).map(Bin.Leaf(_)), + 2 -> Gen.zip(genBin(depth - 1), genBin(depth - 1)).map((l, r) => Bin.Branch(l, r)), + ) + + private given Arbitrary[Bin] = Arbitrary(genBin(5)) + + // one layer of BinF over arbitrary Bin children + private given Arbitrary[BinF[Bin]] = Arbitrary( + Gen.oneOf( + Gen.choose(0, 20).map(BinF.LeafF(_)), + Gen.zip(genBin(4), genBin(4)).map((l, r) => BinF.BranchF(l, r)), + ) + ) + + // ----- Project/Embed coherence ----- + + "embed(project(s)) == s (round-trip through one S layer)" >> prop { (s: Bin) => + val basis = summon[Basis[BinF, Bin]] + (basis.embed(basis.project(s)) == s) must beTrue + } + + "project(embed(fs)) == fs (round-trip through one F layer)" >> prop { (fs: BinF[Bin]) => + val basis = summon[Basis[BinF, Bin]] + (basis.project(basis.embed(fs)) == fs) must beTrue + } + + // same coherence laws for the N-ary RoseF/Rose basis (a swapped label/kids mapping would escape + // the node-count behaviour tests, so the correspondence is pinned here) + private def genRose(depth: Int): Gen[Rose] = + for + label <- Gen.choose(0, 20) + kids <- + if depth <= 0 then Gen.const(List.empty[Rose]) + else Gen.choose(0, 3).flatMap(n => Gen.listOfN(n, genRose(depth - 1))) + yield Rose(label, kids) + + private given Arbitrary[Rose] = Arbitrary(genRose(3)) + + private given Arbitrary[RoseF[Rose]] = Arbitrary( + for + label <- Gen.choose(0, 20) + n <- Gen.choose(0, 3) + kids <- Gen.listOfN(n, genRose(2)) + yield RoseF(label, kids) + ) + + "embed(project(r)) == r for the N-ary RoseF/Rose basis" >> prop { (r: Rose) => + val basis = summon[Basis[RoseF, Rose]] + (basis.embed(basis.project(r)) == r) must beTrue + } + + "project(embed(fr)) == fr for the N-ary RoseF/Rose basis" >> prop { (fr: RoseF[Rose]) => + val basis = summon[Basis[RoseF, Rose]] + (basis.project(basis.embed(fr)) == fr) must beTrue + } + + // ----- hylo law, pure flavor (generic forAll) ----- + + // seed n -> a right spine of (n+1) leaves of value 1 + private val coalg: Int => BinF[Int] = n => + if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1) + + // pure algebra: ignores the node argument (so it is shape-agnostic to seed-vs-S) + private val pureSum: BinF[Int] => Int = { + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + } + + "hyloF == anaF.cross(cataF) for a PURE algebra (the hylo law)" >> { + forAll(Gen.choose(0, 12)) { (seed: Int) => + val fused = Schemes.hyloF[BinF, Int, Int](coalg, (_, fa) => pureSum(fa)).get(seed) + val materializing = + Schemes + .anaF[BinF, Int, Bin](coalg) + .cross(Schemes.cataF[BinF, Bin, Int]((_, fa) => pureSum(fa))) + .get(seed) + fused == materializing + } + } + + // ----- hylo law, para flavor (point tests, not forAll) ----- + // + // A node-reading algebra: hyloF sees the Int seed at each layer; the materializing path sees the + // rebuilt Bin. They are NOT equal in general — verified here only that fused matches itself and a + // hand-computed value, documenting why forAll does not apply. + + "para-flavored hyloF computes the expected value at specific seeds" >> { + // alg reads neither node meaningfully here but is typed para; leaf-count over the spine + val leafCount: (Int, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 1 + case BinF.BranchF(l, r) => l + r + val h = Schemes.hyloF(coalg, leafCount) + (h.get(0) == 1).and(h.get(3) == 4).and(h.get(7) == 8) + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala new file mode 100644 index 00000000..a183908c --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala @@ -0,0 +1,226 @@ +package dev.constructive.eo +package schemes + +import scala.language.implicitConversions + +import cats.instances.int.given +import org.specs2.mutable.Specification + +import data.{Forget, PSVec} +import data.Forget.given +import optics.{Getter, Optic, Plated} +import optics.Optic.* // get, andThen, cross, foldMap + +import generics.plate +import schemes.samples.{Bin, BinF, Rose, RoseF} + +/** Behaviour spec for the typed pattern-functor schemes (`cataF` / `anaF` / `hyloF`) and `fLayer`. + * Companion law/coherence checks live in `SchemesFLawsSpec`. + * + * Type-safety note (R3): every `gather`/`alg`/`coalg` below pattern-matches `BinF`'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`. + */ +class SchemesFSpec extends Specification: + + // 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))) + + // pure leaf-sum gather (ignores the node S) + private val sumLeaves: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + // ----- cataF (typed fold) ----- + + "cataF folds a Bin to a value through F's named constructors" >> { + val sumG: Getter[Bin, Int] = Schemes.cataF(sumLeaves) + (sumG.get(tree) == 6) must beTrue + } + + "cataF is paramorphism-flavored: gather can read the original node S" >> { + // count Branch nodes by reading the node argument, not the folded children + val branchCount: (Bin, BinF[Int]) => Int = (node, fa) => + val here = node match + case Bin.Branch(_, _) => 1 + case Bin.Leaf(_) => 0 + val below = fa match + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => l + r + here + below + (Schemes.cataF(branchCount).get(tree) == 2) must beTrue + } + + "cataF-as-Getter composes onto an outer Getter via andThen" >> { + val composed: Getter[(String, Bin), Int] = + Getter[(String, Bin), Bin](_._2).andThen(Schemes.cataF(sumLeaves)) + (composed.get(("x", tree)) == 6) must beTrue + } + + // ----- anaF (typed build) ----- + + "anaF builds a Bin from a seed, then cataF 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.anaF[BinF, Int, Bin](spine).reverseGet(3) + // seed 3 -> Branch(Branch(Branch(Leaf 1, Leaf 1), Leaf 1), Leaf 1): 4 leaves of 1, depth 3 + (Schemes.cataF(sumLeaves).get(built) == 4).and( + Schemes + .cataF[BinF, Bin, Int]((_, fa) => + fa match + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, _) => 1 + l + ) + .get(built) == 3 + ) + } + + "anaF.cross(cataF) is the materializing hylo (builds the Bin, then folds)" >> { + val spine: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(n - 1, -1) + val refold = Schemes.anaF[BinF, Int, Bin](spine).cross(Schemes.cataF(sumLeaves)) + (refold.get(3) == 4) must beTrue + } + + // ----- hyloF (fused refold, no intermediate Bin) ----- + + "hyloF 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) + val leafCount: (Int, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 1 + case BinF.BranchF(l, r) => l + r + // seed 3 -> a right spine of 4 leaves + (Schemes.hyloF(coalg, leafCount).get(3) == 4) must beTrue + } + + "hyloF 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 leafCount: (Int, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 1 + case BinF.BranchF(l, r) => l + r + val toStr: Getter[Int, String] = + Schemes.hyloF(coalg, leafCount).andThen(Getter[Int, String](_.toString)) + (toStr.get(3) == "4") must beTrue + } + + // ----- edge cases ----- + + "cataF / hyloF handle a single leaf (no recursive positions)" >> { + (Schemes.cataF(sumLeaves).get(Bin.Leaf(7)) == 7).and( + Schemes + .hyloF[BinF, Int, Int]( + n => BinF.LeafF(n), + (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r, + ) + .get(42) == 42 + ) + } + + "cataF handles a one-level Branch(Leaf, Leaf)" >> { + (Schemes.cataF(sumLeaves).get(Bin.Branch(Bin.Leaf(4), Bin.Leaf(5))) == 9) must beTrue + } + + // ----- cross-path equivalence to the #23 PSVec cata ----- + + "cataF agrees with the #23 Plated-driven cata on the same computation" >> { + given Plated[Bin] = plate[Bin] + val psvecSum: (Bin, PSVec[Int]) => Int = (node, kids) => + node match + case Bin.Leaf(n) => n + case Bin.Branch(_, _) => kids.toList.sum + (Schemes.cata(psvecSum).get(tree) == Schemes.cataF(sumLeaves).get(tree)) + .and(Schemes.cataF(sumLeaves).get(tree) == 6) + } + + // ----- fLayer: the single-layer Forget[F] optic (R1) ----- + + "fLayer is a usable Optic[S,S,S,S,Forget[F]]: to/from round-trip one layer" >> { + val layer: Optic[Bin, Bin, Bin, Bin, Forget[BinF]] = Schemes.fLayer[BinF, Bin] + val b = Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)) + (layer.from(layer.to(b)) == b) must beTrue + } + + "fLayer reads its layer's immediate foci via foldMap (Foldable[BinF])" >> { + val layer = Schemes.fLayer[BinF, Bin] + // two immediate children of a Branch + (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 + } + + // ----- stack-safety (R2): 10^6 deep ----- + // + // cataF and hyloF descend a 10^6-deep spine; anaF additionally materializes an O(n) Bin. The + // Eval.defer trampoline moves the recursion off the JVM call stack onto the heap, so these + // complete without StackOverflowError where a naive recursion would overflow. Space is O(depth) + // but the Eval chain is allocation-heavy (several Eval nodes per layer) — a large constant the + // deferred JMH bench will quantify — so the module forks its tests with a generous heap (build.sbt). + + private val Deep = 1_000_000 + + "cataF 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 + val depth: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + (Schemes.cataF(depth).get(b) == Deep) must beTrue + } + + "hyloF 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) + val depth: (Int, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + (Schemes.hyloF(coalg, depth).get(Deep) == Deep) must beTrue + } + + "anaF 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.anaF[BinF, Int, Bin](coalg).reverseGet(Deep) + val depth: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + (Schemes.cataF(depth).get(deep) == Deep) must beTrue + } + + // ----- wide-and-deep: exercise the Traverse[F] sequencing path a binary spine misses ----- + + "cataF/hyloF stay safe on a wide-AND-deep RoseF (high fanout + deep)" >> { + val DeepRose = 100_000 + val Width = 8 + // each level: one deep child (d-1) + Width leaf children (-1 -> empty RoseF) + val coalg: Int => RoseF[Int] = d => + if d <= 0 then RoseF(0, Nil) + else RoseF(d, (d - 1) :: List.fill(Width)(-1)) + val countNodes: (Int, RoseF[Int]) => Int = (_, fr) => 1 + fr.kids.sum + // nodes = (DeepRose+1) spine nodes + DeepRose*Width leaves + val expected = (DeepRose + 1) + DeepRose * Width + (Schemes.hyloF(coalg, countNodes).get(DeepRose) == expected) must beTrue + } + + // anaF + cataF over the N-ary RoseF (the hyloF case above never builds/folds a real Rose, so + // this is the only test exercising the Traverse[RoseF]+Eval sequencing for those two schemes). + "anaF builds and cataF 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: (Rose, RoseF[Int]) => Int = (_, fr) => 1 + fr.kids.sum + val built: Rose = Schemes.anaF[RoseF, Int, Rose](coalg).reverseGet(DeepRose) + val expected = (DeepRose + 1) + DeepRose * Width + (Schemes.cataF(countNodes).get(built) == expected) must beTrue + } 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..ead40916 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/samples/PatternFunctors.scala @@ -0,0 +1,79 @@ +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.cataF]] / `anaF` / `hyloF`). 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/site/docs/schemes.md b/site/docs/schemes.md index 95dad240..cb8397cc 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -5,8 +5,10 @@ > 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. +> a compile error. If you want **named-constructor type safety**, the opt-in typed pattern-functor +> path at the bottom of this page (`cataF`/`anaF`/`hyloF`) gives it — at the cost of writing a +> pattern functor `F` and its `Traverse`/`Project`/`Embed`. The two paths complement each other; +> this `PSVec` path stays the zero-boilerplate default. > > 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` @@ -237,3 +239,106 @@ type `A` with structural equality (any case class, enum, or primitive). 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. + +## Typed pattern-functor schemes — `cataF` / `anaF` / `hyloF` + +The schemes above thread children through `PSVec[AnyRef]`: fast, but the algebra indexes them +positionally (`kids(0)`, `kids(1)`), so an arity slip is a runtime error. The **typed** path trades +a little boilerplate for compile-time safety: 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** instead. + +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. + +```scala mdoc:silent +import cats.{Applicative, Eval, Traverse} +import dev.constructive.eo.schemes.Basis // `Schemes`, `DirectGetter`, `get` already imported above + +// 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))) +``` + +`cataF` 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: DirectGetter[Bin, Int] = + Schemes.cataF[BinF, Bin, Int] { (_, folded) => + folded match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + } +``` + +```scala mdoc +sumLeavesF.get(binTree) +``` + +`anaF` builds an `S` from a seed via a single fused coalgebra `Seed => F[Seed]`; `Embed` glues each +layer. `hyloF` is the **fused** refold (`Seed => A`, no intermediate `Bin`) and needs only +`Traverse[F]`: + +```scala mdoc:silent +// build a right spine of (n+1) unit leaves +val buildBin = Schemes.anaF[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: DirectGetter[Int, Int] = + Schemes.hyloF[BinF, Int, Int]( + coalg = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1), + alg = (_, folded) => + folded match + case BinF.LeafF(_) => 1 + case BinF.BranchF(l, r) => l + r, + ) +``` + +```scala mdoc +sumLeavesF.get(buildBin.reverseGet(3)) // 4 unit leaves +countLeavesF.get(3) // same count, fused — no Bin materialised +countLeavesF.get(1000000) // stack-safe: an Eval trampoline, O(depth) heap +``` + +Like their `PSVec` counterparts, `cataF`/`hyloF` are `DirectGetter`s and `anaF` is a `Review`, so +they compose with the rest of the optic algebra via `andThen` and `cross` (the materializing +`anaF(…).cross(cataF(…))` equals the fused `hyloF` for a pure algebra — the hylo law). They run on a +`cats.Eval` trampoline over your `Traverse[F]`, stack-safe to depths a hand-written recursion would +overflow. **Choosing a path:** reach for `cata`/`ana`/`hylo` (default) when you want zero +boilerplate; reach for `cataF`/`anaF`/`hyloF` when you want the algebra to be type-checked against +named constructors. Deriving `Project`/`Embed` from the `S`↔`F` correspondence is future work; today +they are hand-written (as above). + +The single peel/glue layer is also available on its own as `Schemes.fLayer[F, S]`, an +`Optic[S, S, S, S, Forget[F]]` (`to = project`, `from = embed`) — the typed analogue of `Plated`'s +`plate` for one layer. Given a `Foldable[F]` it reads a node's immediate foci via `.foldMap`. It is +primarily the proof that a typed `F` is an optic carrier; the recursive schemes drive `project`/ +`embed` themselves rather than composing `fLayer`. From 357804c7e0031c7a5be474f97526b7333ee5168b Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Tue, 9 Jun 2026 19:40:52 +0200 Subject: [PATCH 02/61] docs(plans): mark typed-recursion-schemes plan completed Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/2026-06-09-002-feat-typed-recursion-schemes-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ee389633..19f4eef4 100644 --- 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 @@ -1,7 +1,7 @@ --- title: "feat: Typed pattern-functor recursion schemes (cataF/anaF/hyloF)" type: feat -status: active +status: completed date: 2026-06-09 origin: docs/brainstorms/2026-06-09-pattern-functor-carrier-requirements.md deepened: 2026-06-09 From 6d2f6f887510bf2f562de96abdbd679055c0b4ab Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Tue, 9 Jun 2026 19:53:45 +0200 Subject: [PATCH 03/61] perf(schemes): JMH bench for typed cataF/anaF/hyloF vs droste basic (U6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the eoF (typed Eval) rows to SchemesBench alongside eo (PSVec) / droste / hand, and upgrades the bench fixture's Functor[BinF] to a Traverse[BinF] (+ a Basis[BinF, Bin]) so the typed schemes run on the same 2^12 workload. Result (-prof gc, B/op, 8191 nodes): the Eval trampoline is ~8-16x droste basic (cata 15.7x, hylo 7.9x, ana 8.2x) — ~316 B/node of Eval machinery. It MISSES the allocation-parity bar, so it ships as the correct/type-safe/stack-safe v1 with allocation as a documented tradeoff; the explicit typed-heap-machine driver becomes a tracked follow-up (no longer a v1-conditional). droste basic is neither stack-safe nor optic-composable, so the comparison is not apples-to-apples. Documents the table + decision in site/docs/benchmarks.md (new recursion-schemes section) and resolves the Eval-vs-heap Open Question in the plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../constructive/eo/bench/SchemesBench.scala | 20 ++++- .../eo/bench/fixture/SchemesFixtures.scala | 62 +++++++++++++-- ...9-002-feat-typed-recursion-schemes-plan.md | 23 ++++-- site/docs/benchmarks.md | 77 +++++++++++++++++++ 4 files changed, 166 insertions(+), 16 deletions(-) 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..736ff815 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -10,11 +10,17 @@ 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`). + * `Review`, fused `hylo` a `Getter`), all on one stack-safe `PSVec` heap machine. + * - **eoF** — the *typed* pattern-functor path (`cataF`/`anaF`/`hyloF` over `BinF` via a `Basis` + * + `Traverse[BinF]`), a `cats.Eval` trampoline. This row quantifies the typed path's + * allocation against droste's basic schemes — the U6 measurement that informs the + * Eval-vs-explicit-heap-machine driver decision. + * - **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 @@ -44,21 +50,29 @@ class SchemesBench extends JmhDefaults: 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 eoCataFG = Schemes.cataF(eoTypedSum) // DirectGetter[Bin, Int] + val eoHyloFG = Schemes.hyloF(eoTypedCoalg, eoTypedHyloAlg) // DirectGetter[Int, Int] + val eoAnaFR = Schemes.anaF[BinF, Int, Bin](eoTypedCoalg) // Review[Bin, Int] + val drosteCataF: Fix[BinF] => Int = scheme.cata(drosteSum) val drosteHyloF: Int => Int = scheme.hylo(drosteSum, drosteBuild) val drosteAnaF: Int => Fix[BinF] = scheme.ana(drosteBuild) // ----- cata: fold a prebuilt tree to its leaf-sum -------------------------- @Benchmark def eoCata: Int = eoCataG.get(eoTree) + @Benchmark def eoCataF: Int = eoCataFG.get(eoTree) @Benchmark def drosteCata: Int = drosteCataF(fixTree) @Benchmark def handCata: Int = handSum(eoTree) // ----- hylo: build + fold from a seed, fused (no intermediate tree) -------- @Benchmark def eoHylo: Int = eoHyloG.get(Depth) + @Benchmark def eoHyloF: Int = eoHyloFG.get(Depth) @Benchmark def drosteHylo: Int = drosteHyloF(Depth) @Benchmark def handHylo: Int = SchemesFixtures.handHylo(Depth) // ----- ana: build the tree from a seed (materializing) --------------------- @Benchmark def eoAna: Bin = eoAnaR.reverseGet(Depth) + @Benchmark def eoAnaF: Bin = eoAnaFR.reverseGet(Depth) @Benchmark def drosteAna: Fix[BinF] = drosteAnaF(Depth) @Benchmark def handAna: Bin = handBuild(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..4999025c 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,13 @@ 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.data.PSVec +import dev.constructive.eo.schemes.{Basis, Schemes} + /** 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 +22,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 foldLeft[A, B](fa: BinF[A], b: B)(f: (B, A) => B): B = + fa match + case BinF.LeafF(_) => b + case BinF.NodeF(l, r) => f(f(b, l), r) - def map[A, B](fa: BinF[A])(f: A => B): BinF[B] = + def foldRight[A, B](fa: BinF[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = fa match - case BinF.LeafF(v) => BinF.LeafF(v) - case BinF.NodeF(l, r) => BinF.NodeF(f(l), f(r)) + 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 `cataF`/`anaF` 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]) ---------------- @@ -60,6 +90,24 @@ object SchemesFixtures: 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))) + // ----- eo TYPED algebras (over the pattern functor BinF via Basis/Traverse) ---------------- + + /** Typed cata gather — the leaf-sum, pattern-matching `BinF`'s named constructors. */ + val eoTypedSum: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(v) => v + case BinF.NodeF(l, r) => l + r + + /** Typed coalgebra (the single fused `Seed => F[Seed]` shape) — builds the perfect binary tree. */ + val eoTypedCoalg: Int => BinF[Int] = d => + if d <= 0 then BinF.LeafF(1) else BinF.NodeF(d - 1, d - 1) + + /** Typed fused-hylo algebra — folds to `Int` directly, never building a `Bin`. */ + val eoTypedHyloAlg: (Int, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 1 + case BinF.NodeF(l, r) => l + r + // ----- hand-wired recursion (the baseline you'd write without either lib) -- def handSum(b: Bin): Int = 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 index 19f4eef4..51b9d557 100644 --- 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 @@ -202,11 +202,18 @@ but its *basic* schemes are stack-unsafe and don't compose as optics. The gap: a `alg: (Seed, F[A]) => A`), needing only `Traverse[F]`. `cataF` needs `Project[F, S]`; `anaF` needs `Embed[F, S]`. -### Deferred to Implementation - -- **Eval vs explicit heap machine (origin R2, B/op-gated):** Ship the `Eval` driver; measure B/op vs - droste basic in U6; build the heap-machine fallback only if `Eval` misses parity. Decided on B/op, - not local ns. +### Resolved During Implementation + +- **Eval vs explicit heap machine (origin R2, B/op-gated): RESOLVED — Eval ships v1; heap machine is + a follow-up.** U6's `-prof gc` measurement (8 191-node tree, `site/docs/benchmarks.md`): the typed + `Eval` path is **~8–16× droste basic** B/op (`cata` 15.7×, `hylo` 7.9×, `ana` 8.2×; ~316 B/node of + `Eval` machinery) — it does **not** meet the parity bar. It ships as the correct / type-safe / + stack-safe v1 (allocation is the documented tradeoff; droste basic is neither stack-safe nor + optic-composable). Reaching parity needs the pre-planned explicit typed-heap-machine driver — now + a tracked follow-up, no longer a v1-conditional. +- **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 @@ -452,7 +459,11 @@ asserted (per `verify-stacksafety-claims`). A failure escalates to the deferred **Verification:** `docs/mdoc` succeeds (the pre-commit gate), 0 errors; snippets render expected output. -- [ ] **Unit 6: JMH benchmark — typed-F vs droste basic (optional / splittable)** +- [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. diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index 6e4a6dfa..1f703dfd 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -434,6 +434,83 @@ 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. +<<<<<<< HEAD +======= +## Recursion schemes — cata / ana / hylo vs droste and hand-written + +`SchemesBench` measures cats-eo's typed recursion schemes against +[droste](https://github.com/higherkindness/droste) and a hand-written fold/unfold +over a fixed-size `Expr` tree (balanced binary tree, ~512 nodes). The typed +schemes (`cataF` / `anaF` / `hyloF`) use the `ArrayDeque`-based heap machine +that replaced the `Eval` trampoline — stack-safe to 10^6 nodes, no forking +required. + +| Method | ns/op | B/op | vs droste (ns) | vs hand (ns) | +|---|--:|--:|--:|--:| +| `handCata` | 13 170 | 0 | — | 1× | +| `handHylo` | 11 358 | 0 | — | 1× | +| `handAna` | 19 059 | 163 816 | — | 1× | +| `drosteCata` | 44 535 | 164 824 | 1× | 3.4× | +| `drosteHylo` | 76 215 | 328 641 | 1× | 6.7× | +| `drosteAna` | 55 247 | 327 632 | 1× | 2.9× | +| `eoCata` | 85 627 | 197 569 | 1.9× | 6.5× | +| `eoHylo` | 85 523 | 295 849 | 1.1× | 7.5× | +| `eoAna` | 144 313 | 786 297 | 2.6× | 7.6× | + +Three results: + +- **`cata` / `hylo` are ~1.9× / ~1.1× droste in ns** and ~1.2× / ~0.9× in B/op. + The hylo gap has closed to noise; cata carries a small constant from the typed + `CoAlgebra[F[_], A]` dispatch that droste's `Gather`-based scheme avoids. +- **`ana` is the weakest link** (~2.6× droste ns, ~2.4× B/op). The unfold leg + allocates a `Frame` per node to track the output position; this is the primary + allocation driver and the main gap to close. +- **All three are ~6–8× behind hand-written** in ns; hand-written `cata` / `hylo` + are essentially alloc-free (0 B/op) because the JIT fuses the in-place fold — + no intermediate carrier representation, no `Frame`. The B/op gap is the + structural cost of keeping the scheme compositional (optic-native) rather than + fused into a single recursive function. + +The earlier memory note "eo schemes ~15–20× slower than droste/hand" was from a +pre-optimisation spike on an untuned encoding; the current `ArrayDeque` heap +machine closes that to ~2× droste (cata) / parity (hylo). + +## Recursion schemes — `cata` / `ana` / `hylo`: eo, typed eo, droste, hand + +`SchemesBench` folds/builds a perfect binary tree of `2^12` (8 191 nodes) four ways: +**eo** (the `PSVec` Plated machine — `cata`/`ana`/`hylo` from `cats-eo-schemes`), +**eoF** (the *typed* pattern-functor path — `cataF`/`anaF`/`hyloF` over `BinF` via a `Basis` + +`Traverse[BinF]`, a `cats.Eval` trampoline), **droste** (`scheme.cata/ana/hylo` over `Fix[BinF]`), +and **hand** (plain recursion). Allocation is the trustworthy signal here (`gc.alloc.rate.norm`, +deterministic and box-independent — ns/op on the shared box is too noisy to compare). + +| Scheme | eo (PSVec) | eoF (typed `Eval`) | droste basic | hand | eoF ÷ droste | +|---|--:|--:|--:|--:|--:| +| `cata` | 197 568 | 2 589 211 | 164 824 | 0.045 | 15.7× | +| `hylo` | 295 849 | 2 589 213 | 328 641 | 0.153 | 7.9× | +| `ana` | 589 713 | 2 686 489 | 327 632 | 163 816 | 8.2× | + +(B/op at depth 12.) + +Two readings: + +- **The `PSVec` path is droste-competitive.** eo `cata` is ~1.2× droste, `hylo` *beats* droste + (0.9×, the fused refold builds no intermediate tree), `ana` ~1.8×. The constant is carrier + materialisation (one `PSVec` + `out` array per node), same as Plated above. +- **The typed `Eval` path is ~8–16× droste basic** (and ~13× eo's own `PSVec` path): ~316 B/node of + `Eval` machinery — a `Defer` per child, a `FlatMap`+`Map` from `map2`, and the `.map(alg)` per + layer. This is inherent to the `cats.Eval` trampoline, not a bug. It is the price of being + **typed *and* stack-safe at once**: droste's *basic* schemes are neither stack-safe (naive + recursion) nor optic-composable, so the row is not apples-to-apples — eoF delivers guarantees + droste basic does not. + +**Decision (U6):** the `Eval` driver does **not** meet allocation parity with droste basic, so it +ships as the correct / type-safe / stack-safe v1 with allocation as a documented tradeoff. Reaching +parity would require the pre-planned **explicit typed-heap-machine** driver (walk `F` through its +`Foldable`/`Traverse` into a `PSVec`-style `ArrayDeque` engine, without re-erasing children to +`AnyRef` at the algebra seam) — tracked as a follow-up. Until then, prefer the `PSVec` `cata`/`ana`/ +`hylo` when allocation matters, and `cataF`/`anaF`/`hyloF` when named-constructor type-safety does. + ## Reproducing The integration tables are produced by the **Benchmarks** CI workflow From 0b7aebbf2d97589bc9bb6711054b696c65b7a0e2 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Tue, 9 Jun 2026 20:37:02 +0200 Subject: [PATCH 04/61] perf(schemes): replace typed Eval driver with the foldLayered heap machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U6 profiling showed the Eval-trampoline cataF/anaF/hyloF at ~8-16x droste-basic B/op (~316 B/node of Eval machinery). Replaced it with the pre-planned explicit machine: the same <512-on-stack / heap-ArrayDeque hybrid as the PSVec engines, keeping F typed at the algebra seam. - foldLayered: foldLeft reads a layer's children into a per-node array (reused as the result accumulator, folded in place); the deep recursion runs on the machine; map rebuilds the typed F[result] the algebra destructures. - rebuildLayer: leaf layers (0 N-slots) skip the rebuild via a phantom F[N]->F[R] recast — half the nodes in a binary tree. Shared EmptyAnyRefs for leaf arrays. - Any lawful Traverse[F] now works: stack-safety is the machine's, not the user's foldRight, so the Eval-lazy-foldRight caveat is gone and the -Xmx2g test fork is removed (10^6 runs in the default heap, like #23's PSVec cases). B/op vs droste basic (SchemesBench, 8191 nodes): cata 2.2x, hylo 1.1x (parity), ana 1.6x (now beats eo's own PSVec ana) — a ~7x allocation cut from the Eval driver. Residual cata gap is inherent native-Bin-vs-Fix (eo projects a layer per node; droste's unfix is free). Still typed + stack-safe, which droste basic isn't. Updates site/docs/benchmarks.md + schemes.md and resolves the Eval-vs-heap Open Question in the plan (heap machine ships; no follow-up needed for parity). Co-Authored-By: Claude Opus 4.8 (1M context) --- build.sbt | 7 - ...9-002-feat-typed-recursion-schemes-plan.md | 46 ++--- .../dev/constructive/eo/schemes/Schemes.scala | 193 ++++++++++++++---- .../eo/schemes/SchemesFSpec.scala | 8 +- site/docs/benchmarks.md | 47 +++-- site/docs/schemes.md | 11 +- 6 files changed, 218 insertions(+), 94 deletions(-) diff --git a/build.sbt b/build.sbt index f60932de..f3c8e3c7 100644 --- a/build.sbt +++ b/build.sbt @@ -663,13 +663,6 @@ lazy val schemes: Project = project libraryDependencies += cats, libraryDependencies += discipline % Test, libraryDependencies += scalacheck % Test, - // Fork the tests into a fresh JVM with a generous heap. The typed schemes' `Eval` trampoline is - // O(depth) but allocation-heavy (several `Eval` nodes per layer), so the 10^6-deep stack-safety - // cases need ~1 GB; running them alongside #23's 10^6 cases in sbt's in-process heap OOMs. - // Forking isolates them and makes `sbt test` deterministic. (The deferred JMH bench quantifies - // the allocation; the explicit-heap-machine fallback would shrink it.) - Test / fork := true, - Test / javaOptions += "-Xmx2g", ) // Discipline-style laws for the recursion-scheme module. Lives outside 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 index 51b9d557..e32fefdf 100644 --- 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 @@ -158,22 +158,21 @@ but its *basic* schemes are stack-unsafe and don't compose as optics. The gap: a 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]`). - **Subtlety the tests must guard:** stack-safety holds only when `Traverse[F].traverse` sequences a - node's children through the supplied `Eval` `Applicative` **without on-stack recursion across the - spine** — true for cats-derived instances and bounded-fanout hand-written ones, but a naively - recursive hand-written `Traverse[F]` (or a non-`Eval`-lazy `foldRight`) can reintroduce stack - growth that a binary-spine depth test won't catch. Recommend users *derive* `Traverse[F]` where - possible; U4 adds a wide-and-deep shape to exercise the orthogonal failure mode. -- **Driver mechanism: `Eval` trampoline first (primary), explicit typed heap machine as a deferred, - B/op-gated fallback.** This reconciles the apparent tension with #23's "no `Eval`" choice: #23 had - `Plated` hand it a *flat* `PSVec[S]` of children, making an explicit `ArrayDeque` machine natural - and `Eval` unnecessary. The typed path threads a *generic* `F`, where `Traverse[F] + Eval` (the - `Plated.rewrite` precedent, and droste's `hyloM` shape) is the clean stack-safe primitive. - Different child representation → different best tool; `Eval` here is principled, not a regression - of #23's reasoning. If U6's B/op measurement shows `Eval` misses droste-basic parity, the - fallback is an explicit typed heap machine (Foldable-extract into a buffer, fold, `Traverse`-rebuild - with an index) — more code, must not re-erase `F`'s children to `AnyRef`. This is a perf - optimization, **not** v1-blocking. + 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 @@ -204,13 +203,14 @@ but its *basic* schemes are stack-unsafe and don't compose as optics. The gap: a ### Resolved During Implementation -- **Eval vs explicit heap machine (origin R2, B/op-gated): RESOLVED — Eval ships v1; heap machine is - a follow-up.** U6's `-prof gc` measurement (8 191-node tree, `site/docs/benchmarks.md`): the typed - `Eval` path is **~8–16× droste basic** B/op (`cata` 15.7×, `hylo` 7.9×, `ana` 8.2×; ~316 B/node of - `Eval` machinery) — it does **not** meet the parity bar. It ships as the correct / type-safe / - stack-safe v1 (allocation is the documented tradeoff; droste basic is neither stack-safe nor - optic-composable). Reaching parity needs the pre-planned explicit typed-heap-machine driver — now - a tracked follow-up, no longer a v1-conditional. +- **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`. 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 57d25d7b..689f5a0f 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -5,7 +5,7 @@ import scala.annotation.tailrec import java.util.ArrayDeque -import cats.{Eval, Traverse} +import cats.Traverse import data.{Forget, PSVec} import optics.{Getter, Optic, Plated, Review, Unfold} @@ -56,6 +56,56 @@ object Schemes: */ final private val OnStackLimit = 512 + /** Shared zero-length children array for leaf nodes — avoids a per-leaf empty-array allocation in + * the typed [[foldLayered]] machine. + */ + private val EmptyAnyRefs: Array[AnyRef] = new Array[AnyRef](0) + + /** Engine for the *fold* schemes ([[cata]] / [[hylo]]). `expand` yields a node's children; + * `combine` folds a node plus its already-folded children — re-supplied the node, so it needs no + * per-node closure. Stack-safe for any *terminating* `expand` (past the on-stack limit the + * recursion lives on the heap, so a non-terminating `expand` exhausts the heap — + * `OutOfMemoryError` — rather than overflowing the stack). + */ + private def unfoldFold[N, R](expand: N => PSVec[N], combine: (N, PSVec[R]) => R): N => R = + + def heap(root: N): R = + final class Frame(val node: N, val kids: PSVec[N], val out: Array[AnyRef], var i: Int) + val stack = new java.util.ArrayDeque[Frame]() + var ret: AnyRef = null.asInstanceOf[AnyRef] + def enter(n: N): Unit = + val kids = expand(n) + if kids.isEmpty then ret = combine(n, PSVec.empty[R]).asInstanceOf[AnyRef] + else stack.push(new Frame(n, kids, new Array[AnyRef](kids.length), 0)) + enter(root) + while !stack.isEmpty do + 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 = combine(fr.node, PSVec.unsafeWrap[R](fr.out)).asInstanceOf[AnyRef] + val _ = stack.pop() + ret.asInstanceOf[R] + + def rec(n: N, depth: Int): R = + if depth >= OnStackLimit then heap(n) + else + val kids = expand(n) + val k = kids.length + if k == 0 then combine(n, PSVec.empty[R]) + else + val out = new Array[AnyRef](k) + var i = 0 + while i < k do + out(i) = rec(kids(i), depth + 1).asInstanceOf[AnyRef] + i += 1 + combine(n, PSVec.unsafeWrap[R](out)) + + n0 => rec(n0, 0) + /** 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 @@ -243,18 +293,95 @@ object Schemes: // Typed pattern-functor path — the opt-in, type-safe complement to the PSVec schemes above. // // The user supplies a pattern functor `F[_]` + its `Traverse[F]`, and (for cata/ana) - // `Project[F, S]` / `Embed[F, S]`. Algebras then pattern-match `F`'s NAMED constructors + // `Project[F, S]` / `Embed[F, S]`. Algebras pattern-match `F`'s NAMED constructors // (`case BranchF(l, r) => l + r`) — no `PSVec[AnyRef]`, no positional indexing. See [[Basis]]. // - // Stack-safety here comes from a `cats.Eval` trampoline over `Traverse[F]` (the `Plated.rewrite` - // precedent, and droste's stack-safe `hyloM` shape), NOT the `ArrayDeque` machine above: with a - // generic `F` the children are a typed `F[child]`, and `Traverse[F] + Eval.defer` is the lawful - // stack-safe primitive. Each recursive descent is guarded by `Eval.defer`, so forcing `.value` - // trampolines on the heap (O(depth) space) instead of growing the JVM call stack. The supplied - // `Traverse[F]` must sequence a node's children through the `Eval` applicative WITHOUT on-stack - // recursion (true for cats-derived and bounded-fanout instances) — prefer deriving it. + // These run on the SAME `< 512`-on-stack / heap-`ArrayDeque` hybrid as the PSVec schemes above + // (see [[foldLayered]]) — NOT a `cats.Eval` trampoline. The deep recursion is driven by the + // machine; the user's `Traverse[F]` is used only per *layer* (bounded fanout: `foldLeft` to read a + // node's children, `map` to rebuild the typed `F[result]` for the algebra), never across the + // spine. So stack-safety needs no `Eval`-lazy `foldRight` from the user — any lawful `Traverse[F]` + // works — and allocation is close to the PSVec path (one children/result array + the typed `F` + // layers per node), not the ~Eval-node-per-layer a trampoline would cost. // =========================================================================================== + /** 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. + */ + private def rebuildLayer[F[_], N, R](fn: F[N], out: Array[AnyRef])(using F: Traverse[F]): F[R] = + if out.length == 0 then + fn.asInstanceOf[F[R]] // leaf: no N-slots, so F[N] is phantom-recast to F[R] + else + var i = -1 + F.map(fn) { _ => + i += 1 + out(i).asInstanceOf[R] + } + + /** 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), then calls `combine` with the + * node, its layer `F[N]`, and the children's results (positional, `Foldable` order). `combine` + * rebuilds the typed `F[R]` via [[rebuildLayer]] and applies the user's algebra / embed. Same + * `< 512`-on-stack / heap-`ArrayDeque` hybrid (and stack-safety) as [[unfoldFold]] / + * [[foldInPlace]]; the per-node child array is reused as the result accumulator (folded in + * place). + */ + private def foldLayered[F[_], N, R]( + expand: N => F[N], + combine: (N, F[N], Array[AnyRef]) => R, + )(using F: Traverse[F]): N => R = + + def childrenArr(fn: F[N]): Array[AnyRef] = + val n = F.size(fn).toInt + if n == 0 then EmptyAnyRefs + else + val arr = new Array[AnyRef](n) + val _ = F.foldLeft(fn, 0) { (i, child) => + arr(i) = child.asInstanceOf[AnyRef] + i + 1 + } + arr + + def heap(root: N): R = + final class Frame(val node: N, val layer: F[N], val arr: Array[AnyRef], var i: Int) + val stack = new java.util.ArrayDeque[Frame]() + var ret: AnyRef = null.asInstanceOf[AnyRef] + def enter(n: N): Unit = + val layer = expand(n) + val arr = childrenArr(layer) + if arr.length == 0 then ret = combine(n, layer, arr).asInstanceOf[AnyRef] + else stack.push(new Frame(n, layer, arr, 0)) + enter(root) + while !stack.isEmpty do + 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[N] + fr.i += 1 + enter(child) + else + ret = combine(fr.node, fr.layer, fr.arr).asInstanceOf[AnyRef] + val _ = stack.pop() + ret.asInstanceOf[R] + + def rec(n: N, depth: Int): R = + if depth >= OnStackLimit then heap(n) + else + val layer = expand(n) + val arr = childrenArr(layer) + val k = arr.length + if k == 0 then combine(n, layer, arr) + else + var i = 0 + while i < k do + arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] + i += 1 + combine(n, layer, arr) + + n => rec(n, 0) + /** The single *layer* optic for a pattern functor `F`: `project`/`embed` worn as the existing * [[dev.constructive.eo.data.Forget]] carrier. `to = project: S => F[S]`, `from = embed: F[S] => * S`, so it is a genuine `Optic[S, S, S, S, Forget[F]]` with **no change to the `Optic` trait**. @@ -275,51 +402,47 @@ object Schemes: /** Catamorphism over a typed pattern functor `F`, as a composable `Getter`. `alg` sees the * original node `S` (paramorphism-flavored) plus its already-folded children as a typed `F[A]`. - * Pure `F[A] => A` folds ignore the `S`. Stack-safe (`Eval` trampoline) to arbitrary depth in - * O(depth) heap. Requires `Project[F, S]` (to peel each layer) and `Traverse[F]` (to fold it). - * - * @note - * Stack-safety requires a `Traverse[F]` that sequences children through the `Eval` applicative - * without on-stack recursion (cats-derived and bounded-fanout hand-written instances qualify; - * an `Eval`-based `foldRight` is the key) — see the section comment above. + * Pure `F[A] => A` folds ignore the `S`. Stack-safe to arbitrary depth (the [[foldLayered]] + * machine, not a trampoline). Requires `Project[F, S]` (to peel each layer) and `Traverse[F]` + * (any lawful instance — the machine, not the user's `foldRight`, provides stack-safety). */ def cataF[F[_], S, A]( alg: (S, F[A]) => A )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = - def go(s: S): Eval[A] = - F.traverse(P.project(s))(child => Eval.defer(go(child))).map(folded => alg(s, folded)) - Getter[S, A](s => go(s).value) + Getter[S, A]( + foldLayered[F, S, A](P.project, (s, fs, out) => alg(s, rebuildLayer[F, S, A](fs, out))) + ) /** Anamorphism over a typed pattern functor `F`, as a `Review`. The single fused coalgebra `Seed * => F[Seed]` yields one typed layer of child seeds; [[Embed]] assembles each layer into the - * built `S`. Materializing — the built `S` is O(nodes). Stack-safe (`Eval` trampoline), and - * because it also holds the materialized `S`, it is the heaviest of the three on heap. Requires - * `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input before output) to match - * [[hyloF]] and the `PSVec` [[ana]]. - * - * @note Stack-safety requires an `Eval`-sequencing `Traverse[F]` (see the section comment above). + * built `S`. Materializing — the built `S` is O(nodes). Stack-safe (the [[foldLayered]] machine). + * Requires `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input before output) + * to match [[hyloF]] and the `PSVec` [[ana]]. */ def anaF[F[_], Seed, S]( coalg: Seed => F[Seed] )(using F: Traverse[F], E: Embed[F, S]): Review[S, Seed] = - def go(seed: Seed): Eval[S] = - F.traverse(coalg(seed))(child => Eval.defer(go(child))).map(E.embed) - Review[S, Seed](seed => go(seed).value) + Review[S, Seed]( + foldLayered[F, Seed, S]( + coalg, + (_, fSeed, out) => E.embed(rebuildLayer[F, Seed, S](fSeed, out)), + ) + ) /** Hylomorphism over a typed pattern functor `F` — the **fused** refold `Seed => A`, building * **no intermediate `S`** (so it needs neither `Project` nor `Embed`, only `Traverse[F]`). * `coalg` unfolds a seed into one typed layer; `alg` folds the layer's results to `A` (the seed - * is supplied, paramorphism-flavored). Stack-safe (`Eval` trampoline). Equal to + * is supplied, paramorphism-flavored). Stack-safe (the [[foldLayered]] machine). Equal to * `anaF(coalg).cross(cataF(alg))` for a *pure* algebra (the hylo law); for a node-reading para * algebra the two agree only under the seed↔`embed(coalg(seed))` correspondence. - * - * @note - * Stack-safety requires an `Eval`-sequencing `Traverse[F]` (see the section comment above). */ def hyloF[F[_], Seed, A]( coalg: Seed => F[Seed], alg: (Seed, F[A]) => A, )(using F: Traverse[F]): Getter[Seed, A] = - def go(seed: Seed): Eval[A] = - F.traverse(coalg(seed))(child => Eval.defer(go(child))).map(folded => alg(seed, folded)) - Getter[Seed, A](seed => go(seed).value) + Getter[Seed, A]( + foldLayered[F, Seed, A]( + coalg, + (seed, fSeed, out) => alg(seed, rebuildLayer[F, Seed, A](fSeed, out)), + ) + ) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala index a183908c..01935089 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala @@ -157,10 +157,10 @@ class SchemesFSpec extends Specification: // ----- stack-safety (R2): 10^6 deep ----- // // cataF and hyloF descend a 10^6-deep spine; anaF additionally materializes an O(n) Bin. The - // Eval.defer trampoline moves the recursion off the JVM call stack onto the heap, so these - // complete without StackOverflowError where a naive recursion would overflow. Space is O(depth) - // but the Eval chain is allocation-heavy (several Eval nodes per layer) — a large constant the - // deferred JMH bench will quantify — so the module forks its tests with a generous heap (build.sbt). + // foldLayered machine (the same < 512-on-stack / heap-ArrayDeque hybrid as the PSVec schemes) + // moves the deep recursion off the JVM call stack onto the heap, so these complete without + // StackOverflowError where a naive recursion would overflow — in O(depth) space, no Eval chain, + // so they run in the default test heap (no fork needed, like #23's PSVec 10^6 cases). private val Deep = 1_000_000 diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index 1f703dfd..0272ac45 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -480,36 +480,41 @@ machine closes that to ~2× droste (cata) / parity (hylo). `SchemesBench` folds/builds a perfect binary tree of `2^12` (8 191 nodes) four ways: **eo** (the `PSVec` Plated machine — `cata`/`ana`/`hylo` from `cats-eo-schemes`), **eoF** (the *typed* pattern-functor path — `cataF`/`anaF`/`hyloF` over `BinF` via a `Basis` + -`Traverse[BinF]`, a `cats.Eval` trampoline), **droste** (`scheme.cata/ana/hylo` over `Fix[BinF]`), -and **hand** (plain recursion). Allocation is the trustworthy signal here (`gc.alloc.rate.norm`, -deterministic and box-independent — ns/op on the shared box is too noisy to compare). +`Traverse[BinF]`), **droste** (`scheme.cata/ana/hylo` over `Fix[BinF]`), and **hand** (plain +recursion). Allocation is the trustworthy signal here (`gc.alloc.rate.norm`, deterministic and +box-independent — ns/op on the shared box is too noisy to compare). -| Scheme | eo (PSVec) | eoF (typed `Eval`) | droste basic | hand | eoF ÷ droste | +| Scheme | eo (PSVec) | eoF (typed) | droste basic | hand | eoF ÷ droste | |---|--:|--:|--:|--:|--:| -| `cata` | 197 568 | 2 589 211 | 164 824 | 0.045 | 15.7× | -| `hylo` | 295 849 | 2 589 213 | 328 641 | 0.153 | 7.9× | -| `ana` | 589 713 | 2 686 489 | 327 632 | 163 816 | 8.2× | +| `cata` | 197 568 | 361 387 | 164 824 | 0.045 | 2.2× | +| `hylo` | 295 849 | 361 386 | 328 641 | 0.153 | 1.1× | +| `ana` | 589 713 | 524 194 | 327 632 | 163 816 | 1.6× | (B/op at depth 12.) -Two readings: +Both the `PSVec` and typed paths run on the **same `< 512`-on-stack / heap-`ArrayDeque` hybrid** as +`Plated.transform` — no `cats.Eval` trampoline. Two readings: - **The `PSVec` path is droste-competitive.** eo `cata` is ~1.2× droste, `hylo` *beats* droste (0.9×, the fused refold builds no intermediate tree), `ana` ~1.8×. The constant is carrier materialisation (one `PSVec` + `out` array per node), same as Plated above. -- **The typed `Eval` path is ~8–16× droste basic** (and ~13× eo's own `PSVec` path): ~316 B/node of - `Eval` machinery — a `Defer` per child, a `FlatMap`+`Map` from `map2`, and the `.map(alg)` per - layer. This is inherent to the `cats.Eval` trampoline, not a bug. It is the price of being - **typed *and* stack-safe at once**: droste's *basic* schemes are neither stack-safe (naive - recursion) nor optic-composable, so the row is not apples-to-apples — eoF delivers guarantees - droste basic does not. - -**Decision (U6):** the `Eval` driver does **not** meet allocation parity with droste basic, so it -ships as the correct / type-safe / stack-safe v1 with allocation as a documented tradeoff. Reaching -parity would require the pre-planned **explicit typed-heap-machine** driver (walk `F` through its -`Foldable`/`Traverse` into a `PSVec`-style `ArrayDeque` engine, without re-erasing children to -`AnyRef` at the algebra seam) — tracked as a follow-up. Until then, prefer the `PSVec` `cata`/`ana`/ -`hylo` when allocation matters, and `cataF`/`anaF`/`hyloF` when named-constructor type-safety does. +- **The typed path is now ~1.1–2.2× droste basic** — `hylo` at parity, `ana` 1.6× (it even beats + eo's own `PSVec` `ana`), `cata` 2.2×. The typed driver walks the deep recursion with the array + machine and uses the user's `Traverse[F]` only *per layer* (bounded fanout: `foldLeft` to read a + node's children, `map` to rebuild the typed `F[result]` the algebra destructures); leaf nodes + skip the rebuild via a phantom recast. Earlier this path used a `cats.Eval` trampoline and cost + ~8–16× droste (~316 B/node of `Eval` machinery) — replacing it with the machine cut allocation + ~7×. The residual `cata` gap is **inherent, not waste**: eo folds a *native* `Bin`, so `project` + allocates a `BinF[Bin]` layer per node, where droste folds a `Fix[BinF]` and its `unfix` is free — + the same native-vs-`Fix` cost eo's `PSVec` `cata` pays (197 568). And eoF buys **type-safety + + stack-safety** that droste's *basic* schemes lack (naive recursion, not optic-composable), so the + comparison is not apples-to-apples. + +**Decision (U6):** the typed driver is the `foldLayered` **heap machine** (the pre-planned +explicit-machine option), *not* a trampoline — it reaches allocation parity-to-~2× with droste basic +while staying typed and stack-safe to 10⁶ in the default heap. Reach for the `PSVec` `cata`/`ana`/ +`hylo` when you want zero boilerplate, and `cataF`/`anaF`/`hyloF` when you want named-constructor +type-safety at near-droste allocation. ## Reproducing diff --git a/site/docs/schemes.md b/site/docs/schemes.md index cb8397cc..bd902710 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -325,14 +325,17 @@ val countLeavesF: DirectGetter[Int, Int] = ```scala mdoc sumLeavesF.get(buildBin.reverseGet(3)) // 4 unit leaves countLeavesF.get(3) // same count, fused — no Bin materialised -countLeavesF.get(1000000) // stack-safe: an Eval trampoline, O(depth) heap +countLeavesF.get(1000000) // stack-safe: the heap machine, O(depth) heap ``` Like their `PSVec` counterparts, `cataF`/`hyloF` are `DirectGetter`s and `anaF` is a `Review`, so they compose with the rest of the optic algebra via `andThen` and `cross` (the materializing -`anaF(…).cross(cataF(…))` equals the fused `hyloF` for a pure algebra — the hylo law). They run on a -`cats.Eval` trampoline over your `Traverse[F]`, stack-safe to depths a hand-written recursion would -overflow. **Choosing a path:** reach for `cata`/`ana`/`hylo` (default) when you want zero +`anaF(…).cross(cataF(…))` equals the fused `hyloF` for a pure algebra — the hylo law). They run on +the **same `< 512`-on-stack / heap-`ArrayDeque` machine** as the `PSVec` schemes (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)). **Choosing a path:** reach for `cata`/`ana`/`hylo` (default) when you +want zero boilerplate; reach for `cataF`/`anaF`/`hyloF` when you want the algebra to be type-checked against named constructors. Deriving `Project`/`Embed` from the `S`↔`F` correspondence is future work; today they are hand-written (as above). From af5a91fa0e364aebfde68453ae98ac8fa9c964df Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Tue, 9 Jun 2026 20:53:29 +0200 Subject: [PATCH 05/61] docs(schemes): lens-composition example for the typed schemes Adds a worked example + test: compose a lens chain (innerL.andThen(treeL): Lens[Doc, Bin]) to focus a recursive field inside a record, fold it with cataF (wrapping the lens read in a Getter, per eo's read-only-composition idiom), and write the field back through the same composed lens. Demonstrates the "schemes are optics" value prop concretely. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../constructive/eo/bench/SchemesBench.scala | 8 +++--- .../eo/schemes/SchemesFSpec.scala | 25 ++++++++++++++++- site/docs/schemes.md | 28 +++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) 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 736ff815..04633df2 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -46,13 +46,13 @@ 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 eoCataG = Schemes.cata(eoSum) // Getter[Bin, Int] + val eoHyloG = Schemes.hylo(eoExpand, eoHyloAlg) // Getter[Int, Int] val eoAnaR = Schemes.ana(eoAnaCoalg) // Review[Bin, Int] // typed pattern-functor path (Eval trampoline over Traverse[BinF]) - val eoCataFG = Schemes.cataF(eoTypedSum) // DirectGetter[Bin, Int] - val eoHyloFG = Schemes.hyloF(eoTypedCoalg, eoTypedHyloAlg) // DirectGetter[Int, Int] + val eoCataFG = Schemes.cataF(eoTypedSum) // Getter[Bin, Int] + val eoHyloFG = Schemes.hyloF(eoTypedCoalg, eoTypedHyloAlg) // Getter[Int, Int] val eoAnaFR = Schemes.anaF[BinF, Int, Bin](eoTypedCoalg) // Review[Bin, Int] val drosteCataF: Fix[BinF] => Int = scheme.cata(drosteSum) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala index 01935089..c545d1f9 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala @@ -8,7 +8,7 @@ import org.specs2.mutable.Specification import data.{Forget, PSVec} import data.Forget.given -import optics.{Getter, Optic, Plated} +import optics.{Getter, Lens, Optic, Plated} import optics.Optic.* // get, andThen, cross, foldMap import generics.plate @@ -60,6 +60,29 @@ class SchemesFSpec extends Specification: (composed.get(("x", tree)) == 6) must beTrue } + "a composed Lens focuses a recursive field; the scheme folds it; the lens still writes" >> { + final case class Inner(label: String, tree: Bin) + final case class Doc(id: Int, inner: Inner) + + 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 doc = Doc(1, Inner("x", tree)) // tree = leaf sum 6 + // read: focus the recursive field with the composed lens, fold it with the scheme. + // (Schemes are read-only Getters, so we either read at the leaf — `cataF.get(lens.get(doc))` — + // or wrap the lens read in a Getter to build a reusable composed Getter[Doc, Int].) + val docLeafSum: Getter[Doc, Int] = + Getter[Doc, Bin](deepTree.get).andThen(Schemes.cataF(sumLeaves)) + + val pruned = deepTree.replace(Bin.Leaf(0))(doc) // write through the same composed lens + + (Schemes.cataF(sumLeaves).get(deepTree.get(doc)) == 6) + .and(docLeafSum.get(doc) == 6) + .and(deepTree.get(pruned) == Bin.Leaf(0)) + .and(pruned.inner.label == "x") // the rest of the record is untouched + } + // ----- anaF (typed build) ----- "anaF builds a Bin from a seed, then cataF reads it back" >> { diff --git a/site/docs/schemes.md b/site/docs/schemes.md index bd902710..e476af15 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -340,6 +340,34 @@ boilerplate; reach for `cataF`/`anaF`/`hyloF` when you want the algebra to be ty named constructors. Deriving `Project`/`Embed` from the `S`↔`F` correspondence is future work; today they are hand-written (as above). +### Composing with lenses + +Because the schemes are `DirectGetter`s, 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. Read-only optics compose +`Getter`-to-`Getter`, so wrap the lens's read in a `Getter` (or just read at the leaf, +`cataF(alg).get(lens.get(record))`) — the same composed lens still *writes* the field back: + +```scala mdoc:silent +import dev.constructive.eo.optics.Lens + +case class Inner(label: String, tree: Bin) +case class Doc(id: Int, inner: Inner) + +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 + +// wrap the composed lens's read in a Getter, then andThen the scheme → reusable Getter[Doc, Int] +val docLeafSum = Getter[Doc, Bin](deepTree.get).andThen(sumLeavesF) + +val record = Doc(1, Inner("x", binTree)) +``` + +```scala mdoc +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 +``` + The single peel/glue layer is also available on its own as `Schemes.fLayer[F, S]`, an `Optic[S, S, S, S, Forget[F]]` (`to = project`, `from = embed`) — the typed analogue of `Plated`'s `plate` for one layer. Given a `Foldable[F]` it reads a node's immediate foci via `.foldMap`. It is From 08df60338f3442b2eebddfbd1933e14d05761a7e Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 00:07:06 +0200 Subject: [PATCH 06/61] fix(schemes): post-rebase reconciliation + BiAffine zoo plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fLayer adapts to main's opaque ForgetK carrier (wrap via ForgetK.apply, unwrap via .value; def-encoded to/from per the val→def encoding). DirectGetter references follow the Getter rename. docs/plans/2026-06-11-001: the twice-reviewed plan growing this PR into the recursion-scheme zoo (BiAffine carrier, Decor family, para/apo/ histo/futu, fused cross, M-generic driver). Co-Authored-By: Claude Fable 5 --- ...06-11-001-feat-biaffine-scheme-zoo-plan.md | 431 ++++++++++++++++++ .../dev/constructive/eo/schemes/Schemes.scala | 6 +- 2 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-06-11-001-feat-biaffine-scheme-zoo-plan.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..bdb0073f --- /dev/null +++ b/docs/plans/2026-06-11-001-feat-biaffine-scheme-zoo-plan.md @@ -0,0 +1,431 @@ +--- +title: "feat: BiAffine carrier + the typed recursion-scheme zoo as optics (para/apo/histo/futu, M-generic driver)" +type: feat +status: draft +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. +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. +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 + +- 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/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala index 689f5a0f..cf67dac8 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -7,7 +7,7 @@ import java.util.ArrayDeque import cats.Traverse -import data.{Forget, PSVec} +import data.{Forget, ForgetK, PSVec} import optics.{Getter, Optic, Plated, Review, Unfold} /** Recursion schemes as composable optics, built on the core optic surface. @@ -397,8 +397,8 @@ object Schemes: def fLayer[F[_], S](using P: Project[F, S], E: Embed[F, S]): Optic[S, S, S, S, Forget[F]] = new Optic[S, S, S, S, Forget[F]]: type X = Any - val to: S => Forget[F][X, S] = s => P.project(s) - val from: Forget[F][X, S] => S = fs => E.embed(fs) + def to(s: S): Forget[F][X, S] = ForgetK(P.project(s)) + def from(fs: Forget[F][X, S]): S = E.embed(fs.value) /** Catamorphism over a typed pattern functor `F`, as a composable `Getter`. `alg` sees the * original node `S` (paramorphism-flavored) plus its already-folded children as a typed `F[A]`. From 63cf7f46520aa064cfee58c9ce15554ea0558d7e Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 00:18:35 +0200 Subject: [PATCH 07/61] =?UTF-8?q?fix(docs):=20schemes.md=20DirectGetter?= =?UTF-8?q?=E2=86=92Getter=20(post-rebase=20rename=20sweep)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- site/docs/schemes.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/site/docs/schemes.md b/site/docs/schemes.md index e476af15..f6752fdd 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -253,7 +253,7 @@ You write three things: the functor `F`, its `cats.Traverse`, and a `Basis` (`Pr ```scala mdoc:silent import cats.{Applicative, Eval, Traverse} -import dev.constructive.eo.schemes.Basis // `Schemes`, `DirectGetter`, `get` already imported above +import dev.constructive.eo.schemes.Basis // `Schemes`, `Getter`, `get` already imported above // A binary tree… enum Bin: @@ -289,7 +289,7 @@ val binTree: Bin = Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) typed `BinF[A]`** — `l` and `r` are `A`, by name, no positional indexing: ```scala mdoc:silent -val sumLeavesF: DirectGetter[Bin, Int] = +val sumLeavesF: Getter[Bin, Int] = Schemes.cataF[BinF, Bin, Int] { (_, folded) => folded match case BinF.LeafF(n) => n @@ -312,7 +312,7 @@ val buildBin = Schemes.anaF[BinF, Int, Bin] { n => } // fused: count the leaves directly, building no Bin -val countLeavesF: DirectGetter[Int, Int] = +val countLeavesF: Getter[Int, Int] = Schemes.hyloF[BinF, Int, Int]( coalg = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1), alg = (_, folded) => @@ -328,7 +328,7 @@ countLeavesF.get(3) // same count, fused — no Bin materiali countLeavesF.get(1000000) // stack-safe: the heap machine, O(depth) heap ``` -Like their `PSVec` counterparts, `cataF`/`hyloF` are `DirectGetter`s and `anaF` is a `Review`, so +Like their `PSVec` counterparts, `cataF`/`hyloF` are `Getter`s and `anaF` is a `Review`, so they compose with the rest of the optic algebra via `andThen` and `cross` (the materializing `anaF(…).cross(cataF(…))` equals the fused `hyloF` for a pure algebra — the hylo law). They run on the **same `< 512`-on-stack / heap-`ArrayDeque` machine** as the `PSVec` schemes (no `cats.Eval` @@ -342,7 +342,7 @@ they are hand-written (as above). ### Composing with lenses -Because the schemes are `DirectGetter`s, they slot into a lens pipeline. Compose a **lens chain** to +Because the schemes are `Getter`s, 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. Read-only optics compose `Getter`-to-`Getter`, so wrap the lens's read in a `Getter` (or just read at the leaf, `cataF(alg).get(lens.get(record))`) — the same composed lens still *writes* the field back: From bdfd2091f699b948cd2dfe2443dd8cd7a69edc64 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 00:18:35 +0200 Subject: [PATCH 08/61] =?UTF-8?q?feat(core):=20BiAffine=20carrier=20+=20Gr?= =?UTF-8?q?aft=20capability=20=E2=80=94=20the=20decoration=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Affine's data shape worn on the build seam: Done = "this slot is already finished — do not call the coalgebra" (payload meaning pinned per optic value via Fst[X]: apo grafts a finished subtree, futu unrolls a prebuilt layer); Step = keep going (focus + one-F-layer leftover via Snd[X]). Carrier-owned instances: ForgetfulFunctor/Fold/Traverse, PartialAccessor, and the new Graft capability (done/step build-channel injection — the vocabulary generic scheme drivers use). No AssociativeFunctor and no Composer bridges: the composition-matrix row is an explicit follow-up. Laws (cats-eo-laws + OpticsLawsSpec): instance laws mirroring AffineLaws plus graft-channel coherence — Done has no focus, folds empty, is map-inert; Step carries exactly its focus. BiAffineSpec pins the citizen-level graft-finality equations (from(Done(w)) == w, to/from round-trips) against a toy full citizen with X = (W, F[W]). Plan: docs/plans/2026-06-11-001 stage 1. Co-Authored-By: Claude Fable 5 --- .../dev/constructive/eo/accessor/Graft.scala | 27 ++++ .../dev/constructive/eo/data/BiAffine.scala | 131 ++++++++++++++++++ .../eo/laws/data/BiAffineLaws.scala | 68 +++++++++ .../laws/data/discipline/BiAffineTests.scala | 44 ++++++ .../dev/constructive/eo/BiAffineSpec.scala | 46 ++++++ .../dev/constructive/eo/OpticsLawsSpec.scala | 30 +++- 6 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 core/src/main/scala/dev/constructive/eo/accessor/Graft.scala create mode 100644 core/src/main/scala/dev/constructive/eo/data/BiAffine.scala create mode 100644 laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala create mode 100644 laws/src/main/scala/dev/constructive/eo/laws/data/discipline/BiAffineTests.scala create mode 100644 tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala 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..7f60366a --- /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 `Decor` family 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/BiAffine.scala b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala new file mode 100644 index 00000000..71ae4e9c --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala @@ -0,0 +1,131 @@ +package dev.constructive.eo +package data + +import cats.{Applicative, Monoid} + +import accessor.{Graft, PartialAccessor} +import forgetful.* + +/** Carrier for the decoration (`Decor`) family of the recursion-scheme zoo — [[Affine]]'s data + * shape worn on the *build* seam. Where `Affine.Miss` means "the read found no focus", + * [[BiAffine.Done]] means "**this slot is already finished** — the engine must not call the + * coalgebra for it". Its payload's meaning is pinned per optic value via the existential `A` + * (`Fst[A]`): an apomorphism's `Done` carries a finished subtree (prefill the slot, O(1) graft); a + * futumorphism's `Done` carries a prebuilt layer (unroll it, still no coalgebra call). + * [[BiAffine.Step]] is the keep-going arm: focus `b` alongside a one-F-layer leftover context + * (`Snd[A]`). + * + * Same `Fst` / `Snd` match-type discipline as [[Affine]]: at every constructor site `A` is a + * concrete `Tuple2` (so `Fst[A]` / `Snd[A]` reduce); carried through an `Optic[…, BiAffine]` + * existential, `A` is abstract and the match types stay inert. + * + * The composition-matrix row (`AssociativeFunctor[BiAffine]`, `Composer` bridges) is deliberately + * NOT shipped here — it is follow-up work; the laws that need it (`Done`/`Step` coherence across + * `andThen`) live with it. + * + * @tparam A + * existential leftover tuple + * @tparam B + * focus type + */ +sealed trait BiAffine[A, B]: + import BiAffine.* + + /** Monomorphic fold — pattern-match on Done/Step and run the matching branch. + * + * @tparam C + * output type + */ + def fold[C](onDone: Fst[A] => C, onStep: (Snd[A], B) => C): C = this match + case d: Done[A, B] => onDone(d.fst) + case s: Step[A, B] => onStep(s.snd, s.b) + +/** Constructors and typeclass instances for [[BiAffine]]. */ +object BiAffine: + + /** Finished arm — no further building, stores `fst: Fst[A]` directly. `B` is phantom at runtime; + * callers re-typing across a phantom-B change should prefer [[widenB]] over `asInstanceOf`. + */ + final class Done[A, B](val fst: Fst[A]) extends BiAffine[A, B]: + override def toString(): String = s"Done($fst)" + + override def equals(that: Any): Boolean = that match + case other: Done[?, ?] => fst == other.fst + case _ => false + + override def hashCode(): Int = fst.hashCode + + /** Re-type this `Done[A, B]` as `Done[A, B2]` without allocating a new instance. Safe because + * `Done` stores only `fst: Fst[A]` — the `B` parameter is phantom at the runtime shape. + */ + inline def widenB[B2]: Done[A, B2] = this.asInstanceOf[Done[A, B2]] + + /** Keep-going arm: focus present alongside its one-layer leftover context. */ + final class Step[A, B](val snd: Snd[A], val b: B) extends BiAffine[A, B]: + override def toString(): String = s"Step($snd, $b)" + + override def equals(that: Any): Boolean = that match + case other: Step[?, ?] => snd == other.snd && b == other.b + case _ => false + + override def hashCode(): Int = snd.hashCode * 31 + (if b == null then 0 else b.hashCode) + + /** Finished-arm constructor. + * + * @group Constructors + */ + def ofDone[X, B](fst: Fst[X]): BiAffine[X, B] = new Done[X, B](fst) + + /** Keep-going-arm constructor. + * + * @group Constructors + */ + def ofStep[X, B](snd: Snd[X], b: B): BiAffine[X, B] = new Step[X, B](snd, b) + + /** `ForgetfulFunctor[BiAffine]` — maps the focus `B` through the Step arm, passing Done through. + * + * @group Instances + */ + given map: ForgetfulFunctor[BiAffine] with + + def map[X, A, B](fa: BiAffine[X, A], f: A => B): BiAffine[X, B] = fa match + case d: Done[X, A] => new Done[X, B](d.fst) + case s: Step[X, A] => new Step[X, B](s.snd, f(s.b)) + + /** `ForgetfulFold[BiAffine]` — Done empty, Step runs `f` on the focus. + * + * @group Instances + */ + given fold: ForgetfulFold[BiAffine] with + + def foldMap[X, A, M: Monoid](f: A => M, fa: BiAffine[X, A]): M = fa match + case _: Done[X, A] => Monoid[M].empty + case s: Step[X, A] => f(s.b) + + /** `ForgetfulTraverse[BiAffine, Applicative]` — runs `f` on the Step arm, passes Done through via + * `Applicative.pure`. + * + * @group Instances + */ + given traverse: ForgetfulTraverse[BiAffine, Applicative] with + + def traverse[X, A, B, G[_]: Applicative](fa: BiAffine[X, A], f: A => G[B]): G[BiAffine[X, B]] = + fa match + case d: Done[X, A] => Applicative[G].pure(new Done[X, B](d.fst)) + case s: Step[X, A] => Applicative[G].map(f(s.b))(b => new Step[X, B](s.snd, b)) + + /** `PartialAccessor[BiAffine]` — Step has the focus, Done has none. + * + * @group Instances + */ + given partial: PartialAccessor[BiAffine] with + def getOption[X, A](fa: BiAffine[X, A]): Option[A] = fa.fold(_ => None, (_, b) => Some(b)) + + /** `Graft[BiAffine]` — the build-channel injection vocabulary: [[Done]] is the finished arm, + * [[Step]] the keep-going arm. + * + * @group Instances + */ + given graft: Graft[BiAffine] with + def done[X, B](fst: Fst[X]): BiAffine[X, B] = new Done[X, B](fst) + def step[X, B](snd: Snd[X], b: B): BiAffine[X, B] = new Step[X, B](snd, b) diff --git a/laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala new file mode 100644 index 00000000..8c8dcf92 --- /dev/null +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala @@ -0,0 +1,68 @@ +package dev.constructive.eo.laws.data + +import cats.{Applicative, Id} +import dev.constructive.eo.accessor.{Graft, PartialAccessor} +import dev.constructive.eo.data.{BiAffine, Fst, Snd} +import dev.constructive.eo.forgetful.{ForgetfulFold, ForgetfulFunctor, ForgetfulTraverse} + +/** Carrier-level laws for `BiAffine[X, A]` — the decoration carrier of the recursion-scheme zoo + * (`Affine`'s data shape worn on the build seam: `Done` = finished, `Step` = keep going). + * + * Two groups: + * + * - Instance laws mirroring [[AffineLaws]]: `ForgetfulFunctor` identity / composition, + * `ForgetfulTraverse` at `Id`. + * - Graft-channel laws: the `Done` arm is *final* — invisible to the focus (`getOption` empty, + * `foldMap` empty) and inert under `map` — while `Step` carries the focus. These are the + * carrier-shaped halves of D1's "Done is final"; the per-value `graft(Done(t)) == t` equation + * is stated against concrete `Decor` citizens (which pin `Fst[X]`), not here. + * + * The `AssociativeFunctor[BiAffine]` coherence laws are deliberately absent — the carrier ships + * without its composition-matrix row (follow-up PR), so there is no `andThen` for them to govern. + */ +trait BiAffineLaws[X, A]: + + def functorIdentity(fa: BiAffine[X, A])(using + FF: ForgetfulFunctor[BiAffine] + ): Boolean = + FF.map(fa, identity[A]) == fa + + def functorComposition(fa: BiAffine[X, A], f: A => A, g: A => A)(using + FF: ForgetfulFunctor[BiAffine] + ): Boolean = + FF.map(FF.map(fa, f), g) == FF.map(fa, f.andThen(g)) + + /** `traverse[Id]` is `map` — the degenerate case of the traverse identity law. */ + def traverseIdentity(fa: BiAffine[X, A])(using + FT: ForgetfulTraverse[BiAffine, Applicative] + ): Boolean = + FT.traverse[X, A, A, Id](fa, a => a: Id[A])(using Applicative[Id]) == + fa + + /** The finished arm carries no focus. */ + def doneHasNoFocus(fst: Fst[X])(using + G: Graft[BiAffine], + P: PartialAccessor[BiAffine], + ): 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[BiAffine], + P: PartialAccessor[BiAffine], + ): Boolean = + P.getOption(G.step[X, A](snd, a)).contains(a) + + /** `Done` is inert under `map` — finished means finished. */ + def doneMapInert(fst: Fst[X], f: A => A)(using + G: Graft[BiAffine], + FF: ForgetfulFunctor[BiAffine], + ): Boolean = + FF.map(G.done[X, A](fst), f) == G.done[X, A](fst) + + /** `Done` contributes nothing to a fold. */ + def doneFoldEmpty(fst: Fst[X])(using + G: Graft[BiAffine], + FD: ForgetfulFold[BiAffine], + ): 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/BiAffineTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/BiAffineTests.scala new file mode 100644 index 00000000..56ef883a --- /dev/null +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/BiAffineTests.scala @@ -0,0 +1,44 @@ +package dev.constructive.eo.laws.data.discipline + +import cats.Applicative +import dev.constructive.eo.accessor.{Graft, PartialAccessor} +import dev.constructive.eo.data.{BiAffine, Fst, Snd} +import dev.constructive.eo.forgetful.{ForgetfulFold, ForgetfulFunctor, ForgetfulTraverse} +import dev.constructive.eo.laws.data.BiAffineLaws +import org.scalacheck.Prop.forAll +import org.scalacheck.{Arbitrary, Cogen} +import org.typelevel.discipline.Laws + +/** Discipline `RuleSet` for [[BiAffineLaws]]. */ +abstract class BiAffineTests[X, A] extends Laws: + def laws: BiAffineLaws[X, A] + + def biAffine(using + Arbitrary[BiAffine[X, A]], + Arbitrary[A], + Arbitrary[Fst[X]], + Arbitrary[Snd[X]], + Cogen[A], + ForgetfulFunctor[BiAffine], + ForgetfulFold[BiAffine], + ForgetfulTraverse[BiAffine, Applicative], + Graft[BiAffine], + PartialAccessor[BiAffine], + ): RuleSet = + new SimpleRuleSet( + "BiAffine", + "functor identity" -> + forAll((fa: BiAffine[X, A]) => laws.functorIdentity(fa)), + "functor composition" -> + forAll((fa: BiAffine[X, A], f: A => A, g: A => A) => laws.functorComposition(fa, f, g)), + "traverse[Id] identity" -> + forAll((fa: BiAffine[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/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala new file mode 100644 index 00000000..03bfa1f2 --- /dev/null +++ b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala @@ -0,0 +1,46 @@ +package dev.constructive.eo + +import org.specs2.mutable.Specification + +import data.BiAffine +import data.BiAffine.{Done, Step} +import optics.Optic + +/** Behaviour checks for the [[BiAffine]] carrier worn by an optic — the graft-finality equations a + * full `Decor` citizen must satisfy, stated against a toy citizen here (the named `Decor` values + * 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 `Done` payload is a finished result) and `Snd[X] = F[W]` (the one-F-layer + * leftover context carried by `Step`). + */ +class BiAffineSpec extends Specification: + + private type TX = (Int, List[Int]) + + // Toy full citizen: W = Int, F = List. Negative values are "already finished" + // (Done); non-negative ones keep going, carrying one layer of context. + private val toy: Optic[Int, Int, Int, Int, BiAffine] { type X = TX } = + new Optic[Int, Int, Int, Int, BiAffine]: + type X = TX + def to(w: Int): BiAffine[X, Int] = + if w < 0 then new Done[X, Int](w) + else new Step[X, Int](List(w), w) + def from(xb: BiAffine[X, Int]): Int = xb match + case d: Done[X, Int] => d.fst + case s: Step[X, Int] => s.b + + "a full BiAffine citizen" should { + + "treat Done as final: from(Done(w)) == w" in { + (toy.from(new Done[TX, Int](-7)) === -7).and(toy.from(new Done[TX, Int](42)) === 42) + } + + "round-trip the Step 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 Done arm: from(to(w)) == w on finished inputs" in { + List(-1, -100).map(w => toy.from(toy.to(w))) === List(-1, -100) + } + } diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala index 815b5dc8..eb1f0d47 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala @@ -24,7 +24,7 @@ import optics.{ Traversal, Unfold } -import data.{Affine, Direct, Forget, ModifyF, MultiFocus, PSVec} +import data.{Affine, BiAffine, Direct, Forget, ModifyF, MultiFocus, PSVec} import laws.{ AffineFoldLaws, GetterLaws, @@ -45,8 +45,8 @@ import laws.discipline.{ PrismTests, UnfoldTests } -import laws.data.{AffineLaws, ModifyFLaws} -import laws.data.discipline.{AffineTests, ModifyFTests} +import laws.data.{AffineLaws, BiAffineLaws, ModifyFLaws} +import laws.data.discipline.{AffineTests, BiAffineTests, ModifyFTests} import laws.typeclass.AssociativeFunctorLaws import laws.typeclass.discipline.AssociativeFunctorTests @@ -63,6 +63,19 @@ private given arbAffineIntStringBool: Arbitrary[Affine[(Int, String), Boolean]] ) ) +// Arbitrary[BiAffine[(Int, String), Boolean]] — picks between the finished +// Done arm and the keep-going Step arm with equal weight. +private given arbBiAffineIntStringBool: Arbitrary[BiAffine[(Int, String), Boolean]] = + Arbitrary( + Gen.oneOf( + Arbitrary.arbitrary[Int].map(BiAffine.ofDone[(Int, String), Boolean]), + for + s <- Arbitrary.arbitrary[String] + b <- Arbitrary.arbitrary[Boolean] + yield BiAffine.ofStep[(Int, String), Boolean](s, b), + ) + ) + // Arbitrary[BinF[Int]] — equal-weight leaf / branch layers of the UnfoldSpec pattern functor. private given arbBinFInt: Arbitrary[BinF[Int]] = Arbitrary( @@ -310,6 +323,17 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: .affine, ) + // ----- BiAffine carrier laws ------------------------------------ + // The decoration carrier of the recursion-scheme zoo: instance laws + // plus the graft-channel coherences (Done is final / focus-free). + + checkAll( + "BiAffine[(Int, String), Boolean]", + new BiAffineTests[(Int, String), Boolean]: + val laws = new BiAffineLaws[(Int, String), Boolean] {} + .biAffine, + ) + // ----- ModifyF carrier laws ------------------------------------- checkAll( From 1e06c4a9907f3b528313c3a58c76e9d98454f8d0 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 00:22:11 +0200 Subject: [PATCH 09/61] =?UTF-8?q?feat(schemes):=20Attr/Coattr=20=E2=80=94?= =?UTF-8?q?=20histo/futu=20decoration=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-rolled, droste-style (no cats-free dep, no Eval suspension fields the foldLayered machine never suspends on). Attr = cofree-without- laziness (a result decorating each node's full history); Coattr = free-without-suspension (a seed to expand, or a prebuilt layer unrolled without consulting the coalgebra). histo's O(n) decoration space is documented as inherent, not hidden. Plan: docs/plans/2026-06-11-001 stage 2. Co-Authored-By: Claude Fable 5 --- .../dev/constructive/eo/schemes/Decor.scala | 44 ++++++++++++++++++ .../constructive/eo/schemes/DecorSpec.scala | 46 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala new file mode 100644 index 00000000..28ac74cd --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala @@ -0,0 +1,44 @@ +package dev.constructive.eo +package schemes + +/** Decoration data for the typed recursion-scheme zoo (histo / futu) — hand-rolled, droste-style, + * rather than cats-free: no `Eval` suspension fields (the `foldLayered` machine never suspends), + * no dependency, shapes tuned to the engine. + * + * Space honesty: a histomorphism decorates **every** node with its full sub-result history, so a + * fold over n nodes retains O(n) [[Attr]] cells until the algebra releases them. That is inherent + * to course-of-value recursion, not an engine artifact. + */ + +/** 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 + +/** 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]]) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala new file mode 100644 index 00000000..69bb5363 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala @@ -0,0 +1,46 @@ +package dev.constructive.eo +package schemes + +import org.specs2.mutable.Specification + +import schemes.samples.BinF + +/** Unit checks for the decoration data ([[Attr]] / [[Coattr]]) — construction, projection, and + * structural equality over a real pattern functor. The zoo members that consume them (`histoF` / + * `futuF`) carry the behavioural coverage. + */ +class DecorSpec extends Specification: + + // A decorated branch: results 1 and 3 at the leaves, 4 at the root. + private val decorated: Attr[BinF, Int] = + Attr(4, BinF.BranchF(Attr(1, BinF.LeafF(1)), Attr(3, BinF.LeafF(3)))) + + "Attr (cofree-without-laziness)" should { + + "project its head" in { + decorated.head === 4 + } + + "expose each child's full history through tail" in { + decorated.tail === BinF.BranchF(Attr(1, BinF.LeafF(1)), Attr(3, BinF.LeafF(3))) + } + + "forget == head" in { + Attr.forget(decorated) === 4 + } + } + + "Coattr (free-without-suspension)" should { + + "distinguish a seed from a prebuilt layer" in { + val seed: Coattr[BinF, Int] = Coattr.Pure(7) + val layer: Coattr[BinF, Int] = Coattr.Roll(BinF.BranchF(Coattr.Pure(1), Coattr.Pure(2))) + (seed !== layer).and(seed === Coattr.Pure(7)) + } + + "nest prebuilt layers arbitrarily deep" in { + val two: Coattr[BinF, Int] = + Coattr.Roll(BinF.BranchF(Coattr.Roll(BinF.LeafF(1)), Coattr.Pure(9))) + two === Coattr.Roll(BinF.BranchF(Coattr.Roll(BinF.LeafF(1)), Coattr.Pure(9))) + } + } From ff2bdc2930b9d82283af70773cc016f0eebfbb06 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 00:32:32 +0200 Subject: [PATCH 10/61] =?UTF-8?q?feat(schemes):=20Decor=20family=20?= =?UTF-8?q?=E2=80=94=20decorations=20as=20BiAffine=20optics,=20cataF/anaF?= =?UTF-8?q?=20re-derived?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gather/scatter pair of a generalized scheme, reified as optic values over the BiAffine carrier, sides pinned in the type: - DecorGather[F, W, A] = Optic[Unit, W, Unit, A, BiAffine] {X = (Unit, F[W])} fold side, build-only: from = gather (histo's gather IS the Attr constructor); vestigial read throws (Unfold.algebra precedent). - DecorScatter[F, W, A] = Optic[W, W, A, A, BiAffine] {X = (F[W], Unit)} unfold side, full citizen: to = scatter (Step = call the coalgebra, Done = prebuilt layer, no call), from on Step = the POINTED UNIT (gana's pure: ana = id, apo = Right, futu = Coattr.Pure) — unit law to(from(Step((), a))) == Step((), a). Named values: Decor.cata/para/histo (gather) + Decor.ana/apo/futu (scatter). cata/ana/histo/futu are identity-stable singletons; the generic drivers recognise cata/ana by identity and take the direct decoration-free engine path, so the re-derived cataF(alg)/anaF(coalg) are byte-identical in behaviour AND cost. Generic-route honesty documented: Decor.apo unrolls grafts via Project (distApo, O(graft)) and Decor.para re-embeds — the native apoF/paraF engines (next stage) avoid both. New generic overloads: cataF(decor)(galg) (interior gather∘galg, root galg alone — droste's gcata shape) and anaF(decor)(gcoalg) (scatter per slot, root through the unit). Fully typed against the X refinements — no casts at the decoration seam. DecorLawsSpec: per-value gather/scatter equations, unit laws, vestigial throws, identity-stability, fresh-user-id-value vs fast-path agreement (both sides), histo heads-only == cata, futu two-layers-per-step build. Plan: docs/plans/2026-06-11-001 stage 3. Co-Authored-By: Claude Fable 5 --- .../dev/constructive/eo/schemes/Decor.scala | 156 +++++++++++++++++ .../dev/constructive/eo/schemes/Schemes.scala | 66 +++++++- .../eo/schemes/DecorLawsSpec.scala | 159 ++++++++++++++++++ 3 files changed, 373 insertions(+), 8 deletions(-) create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala index 28ac74cd..5a39c839 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala @@ -42,3 +42,159 @@ enum Coattr[F[_], A]: /** A prebuilt layer — unrolled directly, no coalgebra call for this layer. */ case Roll(layer: F[Coattr[F, A]]) + +// =========================================================================================== +// The Decor family — decorations as optics over the BiAffine carrier. +// +// A generalized scheme's decoration is an optic whose existential leftover is one F-layer. +// Sides are pinned in the TYPE, eo-style (read-only/build-only citizens): +// +// - Fold side (DecorGather): build-only. `from` is the GATHER — it consumes +// `Step(layer: F[W], result: A)` and produces the decoration `W` (histo's gather is +// literally the `Attr` constructor). The read side is vestigial (throws, the +// `Unfold.algebra` precedent); `Done` never occurs on this side. +// +// - Unfold side (DecorScatter): a full citizen. `to` is the SCATTER — an affine match +// answering each slot with `Step(_, seed)` (call the coalgebra) or `Done(layer: F[W])` +// (a prebuilt layer; unroll it, no coalgebra call). `from` on the Step arm is the +// POINTED unit — the seed injection `A => W` (gana's `pure`: ana = identity, apo = +// `Right`, futu = `Coattr.Pure`), giving the unit law `to(from(Step((), a))) == +// Step((), a)`. +// +// X pinning per shape: gather side X = (Unit, F[W]) (Snd = the one-F-layer context); scatter +// side X = (F[W], Unit) (Fst = the prebuilt-layer Done payload). The generic drivers in +// [[Schemes]] are fully typed against these refinements — no casts at the seam. +// +// NOTE (generic vs native routes): `Decor.apo`'s generic scatter unrolls a grafted subtree +// through `Project` (droste's distApo — O(graft)). The O(1) graft is the privilege of the +// NATIVE `apoF` engine, which prefills result slots from apo's `Done` directly and never +// consults this value. Likewise `Decor.para`'s generic gather re-embeds the subterm it pairs +// (droste's Gather.para); the native `paraF` pairs subterms from the walked nodes instead. +// =========================================================================================== + +/** Fold-side decoration: gather-only (build-only member). `from` = gather. */ +type DecorGather[F[_], W, A] = + optics.Optic[Unit, W, Unit, A, data.BiAffine] { type X = (Unit, F[W]) } + +/** Unfold-side decoration: scatter (`to`, an affine match) + pointed unit (`from` on Step). */ +type DecorScatter[F[_], W, A] = + optics.Optic[W, W, A, A, data.BiAffine] { type X = (F[W], Unit) } + +/** Named decoration values — the recursion-scheme zoo's vocabulary. */ +object Decor: + import cats.Functor + + import data.BiAffine + import data.BiAffine.{Done, Step} + import optics.Optic + + private def vestigialRead(name: String): Nothing = + throw new UnsupportedOperationException( + s"Decor.$name is gather-only (build-only): its read side is vestigial by specification" + ) + + private def foldSideDone(name: String): Nothing = + throw new UnsupportedOperationException( + s"Decor.$name is a fold-side decoration: Done never occurs on the gather seam" + ) + + private def scatterSideDone(name: String): Nothing = + throw new UnsupportedOperationException( + s"Decor.$name: the pointed unit (from) is inhabited on the Step arm only" + ) + + // ----- fold side (gather) ------------------------------------------------ + + private def mkCata[F[_], A]: DecorGather[F, A, A] = + new Optic[Unit, A, Unit, A, BiAffine]: + type X = (Unit, F[A]) + def to(u: Unit): BiAffine[X, Unit] = vestigialRead("cata") + def from(xb: BiAffine[X, A]): A = xb match + case s: Step[X, A] => s.b + case _: Done[X, A] => foldSideDone("cata") + + private val cataAny: AnyRef = mkCata[[x] =>> Any, Any] + + /** The undecorated fold — gather keeps the result, discards the layer. Identity-stable singleton: + * the generic driver recognises it and takes the direct (decoration-free) route. + */ + def cata[F[_], A]: DecorGather[F, A, A] = cataAny.asInstanceOf[DecorGather[F, A, A]] + + /** Paramorphism decoration: each child slot pairs the original subterm with its result. + * + * Generic-route honesty: this gather *re-embeds* the subterm from the layer (droste's + * `Gather.para`) — the native `paraF` avoids that by pairing subterms from the nodes the machine + * already walks. + */ + def para[F[_]: Functor, S, A](using E: Embed[F, S]): DecorGather[F, (S, A), A] = + new Optic[Unit, (S, A), Unit, A, BiAffine]: + type X = (Unit, F[(S, A)]) + def to(u: Unit): BiAffine[X, Unit] = vestigialRead("para") + def from(xb: BiAffine[X, A]): (S, A) = xb match + case s: Step[X, A] => (E.embed(Functor[F].map(s.snd)(_._1)), s.b) + case _: Done[X, A] => foldSideDone("para") + + private def mkHisto[F[_], A]: DecorGather[F, Attr[F, A], A] = + new Optic[Unit, Attr[F, A], Unit, A, BiAffine]: + type X = (Unit, F[Attr[F, A]]) + def to(u: Unit): BiAffine[X, Unit] = vestigialRead("histo") + def from(xb: BiAffine[X, A]): Attr[F, A] = xb match + case s: Step[X, A] => Attr(s.b, s.snd) + case _: Done[X, A] => foldSideDone("histo") + + private val histoAny: AnyRef = mkHisto[[x] =>> Any, Any] + + /** Histomorphism decoration — the gather IS the [[Attr]] constructor: each node keeps its result + * plus its children's full decorated histories. + */ + def histo[F[_], A]: DecorGather[F, Attr[F, A], A] = + histoAny.asInstanceOf[DecorGather[F, Attr[F, A], A]] + + // ----- unfold side (scatter + pointed unit) ------------------------------- + + private def mkAna[F[_], A]: DecorScatter[F, A, A] = + new Optic[A, A, A, A, BiAffine]: + type X = (F[A], Unit) + def to(w: A): BiAffine[X, A] = new Step[X, A]((), w) + def from(xb: BiAffine[X, A]): A = xb match + case s: Step[X, A] => s.b + case _: Done[X, A] => scatterSideDone("ana") + + private val anaAny: AnyRef = mkAna[[x] =>> Any, Any] + + /** The undecorated unfold — every slot is a seed; the unit is the identity. Identity-stable + * singleton (the generic driver takes the direct route on it). + */ + def ana[F[_], A]: DecorScatter[F, A, A] = anaAny.asInstanceOf[DecorScatter[F, A, A]] + + /** Apomorphism decoration, generic route: `Right(seed)` keeps unfolding, `Left(s)` answers with + * the grafted subtree's projected layer — distApo, O(graft) through `Project`. The O(1) graft is + * the native `apoF` engine's privilege; it never consults this value. + */ + def apo[F[_]: Functor, S, A](using P: Project[F, S]): DecorScatter[F, Either[S, A], A] = + new Optic[Either[S, A], Either[S, A], A, A, BiAffine]: + type X = (F[Either[S, A]], Unit) + def to(w: Either[S, A]): BiAffine[X, A] = w match + case Right(a) => new Step[X, A]((), a) + case Left(s) => new Done[X, A](Functor[F].map(P.project(s))(Left(_))) + def from(xb: BiAffine[X, A]): Either[S, A] = xb match + case s: Step[X, A] => Right(s.b) + case _: Done[X, A] => scatterSideDone("apo") + + private def mkFutu[F[_], A]: DecorScatter[F, Coattr[F, A], A] = + new Optic[Coattr[F, A], Coattr[F, A], A, A, BiAffine]: + type X = (F[Coattr[F, A]], Unit) + def to(w: Coattr[F, A]): BiAffine[X, A] = w match + case Coattr.Pure(a) => new Step[X, A]((), a) + case Coattr.Roll(layer) => new Done[X, A](layer) + def from(xb: BiAffine[X, A]): Coattr[F, A] = xb match + case s: Step[X, A] => Coattr.Pure(s.b) + case _: Done[X, A] => scatterSideDone("futu") + + private val futuAny: AnyRef = mkFutu[[x] =>> Any, Any] + + /** Futumorphism decoration — `Pure(seed)` calls the coalgebra, `Roll(layer)` unrolls the prebuilt + * layer without a call; the unit is `Coattr.Pure`. + */ + def futu[F[_], A]: DecorScatter[F, Coattr[F, A], A] = + futuAny.asInstanceOf[DecorScatter[F, Coattr[F, A], A]] 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 cf67dac8..2eb91b96 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -409,9 +409,36 @@ object Schemes: def cataF[F[_], S, A]( alg: (S, F[A]) => A )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = - Getter[S, A]( - foldLayered[F, S, A](P.project, (s, fs, out) => alg(s, rebuildLayer[F, S, A](fs, out))) - ) + cataF[F, S, A, A](Decor.cata[F, A])(alg) + + /** Generalized (decorated) catamorphism — the gcata of the typed path, with the decoration + * supplied as a [[DecorGather]] optic value. Interior nodes apply `gather ∘ galg` (the + * decoration's `from` consuming `Step(layer, result)`); the **root applies `galg` alone** + * (droste's `gcata` shape). The named zoo members are instances: `cataF(alg)` routes here with + * [[Decor.cata]] (recognised by identity — the direct, decoration-free engine path), `histoF` + * with [[Decor.histo]]; user-written decorations (zygo, dyna, …) run the generic route, which + * pays one decoration dispatch + `Step` per node. + */ + def cataF[F[_], S, W, A]( + decor: DecorGather[F, W, A] + )(galg: (S, F[W]) => A)(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = + if decor.asInstanceOf[AnyRef] eq Decor.cata[F, A] then + // W =:= A by construction of the singleton — the direct engine path, no decoration cost. + val alg = galg.asInstanceOf[(S, F[A]) => A] + Getter[S, A]( + foldLayered[F, S, A](P.project, (s, fs, out) => alg(s, rebuildLayer[F, S, A](fs, out))) + ) + else + val toW: S => W = foldLayered[F, S, W]( + P.project, + (s, fs, out) => + val fw = rebuildLayer[F, S, W](fs, out) + decor.from(new data.BiAffine.Step[(Unit, F[W]), A](fw, galg(s, fw))), + ) + Getter[S, A] { s => + val layer = P.project(s) + galg(s, F.map(layer)(toW)) + } /** Anamorphism over a typed pattern functor `F`, as a `Review`. The single fused coalgebra `Seed * => F[Seed]` yields one typed layer of child seeds; [[Embed]] assembles each layer into the @@ -422,12 +449,35 @@ object Schemes: def anaF[F[_], Seed, S]( coalg: Seed => F[Seed] )(using F: Traverse[F], E: Embed[F, S]): Review[S, Seed] = - Review[S, Seed]( - foldLayered[F, Seed, S]( - coalg, - (_, fSeed, out) => E.embed(rebuildLayer[F, Seed, S](fSeed, out)), + anaF[F, Seed, Seed, S](Decor.ana[F, Seed])(coalg) + + /** Generalized (decorated) anamorphism — the gana of the typed path, with the decoration supplied + * as a [[DecorScatter]] optic value. Each `W` slot is scattered (the decoration's `to`): + * `Step(_, seed)` calls `gcoalg`, `Done(layer)` unrolls the prebuilt layer with **no coalgebra + * call**. The root seed enters through the decoration's pointed unit (`from` on the Step arm — + * gana's `pure`). `anaF(coalg)` routes here with [[Decor.ana]] (identity-recognised direct + * path); `futuF` with [[Decor.futu]]; `Decor.apo` runs the generic distApo route — the O(1) + * graft belongs to the native `apoF` engine. + */ + def anaF[F[_], A, W, S]( + decor: DecorScatter[F, W, A] + )(gcoalg: A => F[W])(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = + if decor.asInstanceOf[AnyRef] eq Decor.ana[F, A] then + // W =:= A by construction of the singleton — the direct engine path. + val coalg = gcoalg.asInstanceOf[A => F[A]] + Review[S, A]( + foldLayered[F, A, S](coalg, (_, fSeed, out) => E.embed(rebuildLayer[F, A, S](fSeed, out))) ) - ) + else + val expand: W => F[W] = w => + decor.to(w) match + case st: data.BiAffine.Step[(F[W], Unit), A] => gcoalg(st.b) + case dn: data.BiAffine.Done[(F[W], Unit), A] => dn.fst + val build: W => S = foldLayered[F, W, S]( + expand, + (_, fw, out) => E.embed(rebuildLayer[F, W, S](fw, out)), + ) + Review[S, A](a => build(decor.from(new data.BiAffine.Step[(F[W], Unit), A]((), a)))) /** Hylomorphism over a typed pattern functor `F` — the **fused** refold `Seed => A`, building * **no intermediate `S`** (so it needs neither `Project` nor `Embed`, only `Traverse[F]`). diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala new file mode 100644 index 00000000..206d747c --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala @@ -0,0 +1,159 @@ +package dev.constructive.eo +package schemes + +import org.specs2.mutable.Specification + +import data.BiAffine +import data.BiAffine.{Done, Step} +import optics.Optic +import schemes.samples.{Bin, BinF} + +/** Decoration laws — the per-value equations of the [[Decor]] vocabulary, plus the + * behaviour-identity of the re-derived `cataF`/`anaF` (the identity fast path must agree with the + * generic decoration route, proven by running a *fresh* user-written id decoration through the + * generic route and comparing). + */ +class DecorLawsSpec extends Specification: + + private val tree: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Leaf(3)) + + private val sumAlg: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + // ----- gather-side equations ---------------------------------------------- + + "Decor.histo gather == the Attr constructor" >> { + val layer: BinF[Attr[BinF, Int]] = + BinF.BranchF(Attr(1, BinF.LeafF(1)), Attr(2, BinF.LeafF(2))) + Decor + .histo[BinF, Int] + .from( + new Step[(Unit, BinF[Attr[BinF, Int]]), Int](layer, 3) + ) === Attr(3, layer) + } + + "Decor.para gather == (re-embedded subterm, result)" >> { + val layer: BinF[(Bin, Int)] = + BinF.BranchF((Bin.Leaf(1), 1), (Bin.Leaf(2), 2)) + Decor + .para[BinF, Bin, Int] + .from( + new Step[(Unit, BinF[(Bin, Int)]), Int](layer, 3) + ) === (Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), 3) + } + + "Decor.cata gather == keep the result, discard the layer" >> { + Decor + .cata[BinF, Int] + .from( + new Step[(Unit, BinF[Int]), Int](BinF.BranchF(1, 2), 9) + ) === 9 + } + + "Decor.cata is identity-stable across instantiations (the fast-path dispatch key)" >> { + (Decor.cata[BinF, Int].asInstanceOf[AnyRef] eq + Decor.cata[[x] =>> Option[x], String].asInstanceOf[AnyRef]) === true + } + + "Decor.cata's vestigial read side throws" >> { + val thrown = + try { val _ = Decor.cata[BinF, Int].to(()); false } + catch case _: UnsupportedOperationException => true + thrown === true + } + + // ----- scatter-side equations --------------------------------------------- + + "Decor.ana scatters every value as a Step (no Done arm)" >> { + Decor.ana[BinF, Int].to(7) === new Step[(BinF[Int], Unit), Int]((), 7) + } + + "Decor.ana satisfies the unit law: to(from(Step((), a))) == Step((), a)" >> { + val d = Decor.ana[BinF, Int] + d.to(d.from(new Step[(BinF[Int], Unit), Int]((), 5))) === + new Step[(BinF[Int], Unit), Int]((), 5) + } + + "Decor.futu scatters Pure as Step and Roll as Done(layer)" >> { + val d = Decor.futu[BinF, Int] + val layer: BinF[Coattr[BinF, Int]] = BinF.BranchF(Coattr.Pure(1), Coattr.Pure(2)) + (d.to(Coattr.Pure(4)) === new Step[(BinF[Coattr[BinF, Int]], Unit), Int]((), 4)) + .and(d.to(Coattr.Roll(layer)) === new Done[(BinF[Coattr[BinF, Int]], Unit), Int](layer)) + } + + "Decor.futu's unit is Coattr.Pure" >> { + val d = Decor.futu[BinF, Int] + d.from(new Step[(BinF[Coattr[BinF, Int]], Unit), Int]((), 4)) === Coattr.Pure(4) + } + + "Decor.apo (generic route) scatters Right as Step and Left as the projected layer" >> { + val d = Decor.apo[BinF, Bin, Int] + val grafted = Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)) + (d.to(Right(7)) === new Step[(BinF[Either[Bin, Int]], Unit), Int]((), 7)) + .and( + d.to(Left(grafted)) === new Done[(BinF[Either[Bin, Int]], Unit), Int]( + BinF.BranchF(Left(Bin.Leaf(1)), Left(Bin.Leaf(2))) + ) + ) + } + + "Decor.apo's unit is Right" >> { + val d = Decor.apo[BinF, Bin, Int] + d.from(new Step[(BinF[Either[Bin, Int]], Unit), Int]((), 7)) === Right(7) + } + + // ----- re-derivation behaviour identity ------------------------------------ + + // A FRESH user-written id gather — structurally Decor.cata but a distinct value, + // so the generic driver cannot take the identity fast path. + private val freshIdGather: DecorGather[BinF, Int, Int] = + new Optic[Unit, Int, Unit, Int, BiAffine]: + type X = (Unit, BinF[Int]) + def to(u: Unit): BiAffine[X, Unit] = throw new UnsupportedOperationException("vestigial") + def from(xb: BiAffine[X, Int]): Int = xb match + case s: Step[X, Int] => s.b + case _: Done[X, Int] => throw new UnsupportedOperationException("fold-side Done") + + private val freshIdScatter: DecorScatter[BinF, Int, Int] = + new Optic[Int, Int, Int, Int, BiAffine]: + type X = (BinF[Int], Unit) + def to(w: Int): BiAffine[X, Int] = new Step[X, Int]((), w) + def from(xb: BiAffine[X, Int]): Int = xb match + case s: Step[X, Int] => s.b + case _: Done[X, Int] => throw new UnsupportedOperationException("unit on Step only") + + "the generic decoration route agrees with the identity fast path on cataF" >> { + Schemes.cataF[BinF, Bin, Int, Int](freshIdGather)(sumAlg).get(tree) === + Schemes.cataF[BinF, Bin, Int](sumAlg).get(tree) + } + + "the generic decoration route agrees with the identity fast path on anaF" >> { + def expand(n: Int): BinF[Int] = + if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + Schemes.anaF[BinF, Int, Int, Bin](freshIdScatter)(expand).reverseGet(5) === + Schemes.anaF[BinF, Int, Bin](expand).reverseGet(5) + } + + "histo through Decor.histo: heads-only course-of-value == cata" >> { + val viaHisto = Schemes + .cataF[BinF, Bin, Attr[BinF, Int], Int](Decor.histo[BinF, Int]) { (s, layer) => + sumAlg(s, BinF.traverse.map(layer)(_.head)) + } + .get(tree) + viaHisto === Schemes.cataF[BinF, Bin, Int](sumAlg).get(tree) + } + + "futu through Decor.futu: a two-layer-per-step coalgebra builds the right tree" >> { + // Each step on seed n > 1 emits TWO layers at once: a branch whose left side is + // a prebuilt leaf layer (Roll) and whose right side keeps unfolding (Pure). + def coalg(n: Int): BinF[Coattr[BinF, Int]] = + if n <= 1 then BinF.LeafF(1) + else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) + val built = Schemes + .anaF[BinF, Int, Coattr[BinF, Int], Bin](Decor.futu[BinF, Int])(coalg) + .reverseGet(3) + built === Bin.Branch(Bin.Leaf(3), Bin.Branch(Bin.Leaf(2), Bin.Leaf(1))) + } From 9002b98365678eced6017371ddd12a4e243a0329 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 00:40:05 +0200 Subject: [PATCH 11/61] =?UTF-8?q?feat(schemes):=20the=20typed=20zoo=20?= =?UTF-8?q?=E2=80=94=20paraF=20(no=20re-embed),=20apoF=20(O(1)=20graft),?= =?UTF-8?q?=20histoF,=20futuF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paraF: native route — the machine already walks real S nodes and keeps each frame's projected layer, so child slots pair (original subterm, result) positionally via rebuildLayerPaired. No per-node re-embed (droste's Gather.para must reconstruct the subterm it threw away). apoF: native O(1) graft via foldLayeredOr, foldLayered's graft-aware sibling — Left(s) slots are already-finished results, placed BY REFERENCE: never recursed, never projected. (droste's scatter-apo re-walks grafts through project, O(graft) per graft.) histoF/futuF: definitional one-liners on the generic decorated drivers at Decor.histo / Decor.futu — the proof the Decor family is correctly shaped. SchemesZooSpec: degeneration laws (para-ignoring-subterms == cata, never-grafting apo == ana, heads-only histo == cata, single-layer futu == ana), the graft law (grafted subtree present by `eq` reference — the bench-noise-immune form of the O(1) claim), a real course-of-value algebra (grandchildren through history), and stack-safety to 10^6 per member including deep Coattr chains. Plan: docs/plans/2026-06-11-001 stage 4 (zoo half). Co-Authored-By: Claude Fable 5 --- .../dev/constructive/eo/schemes/Schemes.scala | 131 ++++++++++++++ .../eo/schemes/SchemesZooSpec.scala | 164 ++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala 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 2eb91b96..02cec1a6 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -320,6 +320,21 @@ object Schemes: out(i).asInstanceOf[R] } + /** [[rebuildLayer]]'s paramorphic sibling: pair each original child `N` with its folded result + * from `out` (positional, `Foldable` order). The subterms come from the layer the machine + * already holds — no re-`embed`. + */ + private def rebuildLayerPaired[F[_], N, R](fn: F[N], out: Array[AnyRef])(using + F: Traverse[F] + ): F[(N, R)] = + if out.length == 0 then fn.asInstanceOf[F[(N, R)]] // leaf: no N-slots, phantom-recast + else + var i = -1 + F.map(fn) { n => + i += 1 + (n, out(i).asInstanceOf[R]) + } + /** 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), then calls `combine` with the * node, its layer `F[N]`, and the children's results (positional, `Foldable` order). `combine` @@ -382,6 +397,68 @@ object Schemes: n => rec(n, 0) + /** [[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 `< 512`-on-stack / + * heap-`ArrayDeque` hybrid and stack-safety as [[foldLayered]]. + */ + private def foldLayeredOr[F[_], N, R]( + expandOr: N => Either[R, F[N]], + combine: (F[N], Array[AnyRef]) => R, + )(using F: Traverse[F]): N => R = + + def childrenArr(fn: F[N]): Array[AnyRef] = + val n = F.size(fn).toInt + if n == 0 then EmptyAnyRefs + else + val arr = new Array[AnyRef](n) + val _ = F.foldLeft(fn, 0) { (i, child) => + arr(i) = child.asInstanceOf[AnyRef] + i + 1 + } + arr + + def heap(root: N): R = + final class Frame(val layer: F[N], val arr: Array[AnyRef], var i: Int) + val stack = new java.util.ArrayDeque[Frame]() + var ret: AnyRef = null.asInstanceOf[AnyRef] + def enter(n: N): Unit = expandOr(n) match + case Left(r) => ret = r.asInstanceOf[AnyRef] // graft: finished, by reference + case Right(layer) => + val arr = childrenArr(layer) + if arr.length == 0 then ret = combine(layer, arr).asInstanceOf[AnyRef] + else stack.push(new Frame(layer, arr, 0)) + enter(root) + while !stack.isEmpty do + val fr = stack.peek() + if fr.i > 0 then fr.arr(fr.i - 1) = ret + if fr.i < fr.arr.length then + val child = fr.arr(fr.i).asInstanceOf[N] + fr.i += 1 + enter(child) + else + ret = combine(fr.layer, fr.arr).asInstanceOf[AnyRef] + val _ = stack.pop() + ret.asInstanceOf[R] + + def rec(n: N, depth: Int): R = + if depth >= OnStackLimit then heap(n) + else + expandOr(n) match + case Left(r) => r // graft: finished, by reference + case Right(layer) => + val arr = childrenArr(layer) + val k = arr.length + if k == 0 then combine(layer, arr) + else + var i = 0 + while i < k do + arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] + i += 1 + combine(layer, arr) + + n => rec(n, 0) + /** The single *layer* optic for a pattern functor `F`: `project`/`embed` worn as the existing * [[dev.constructive.eo.data.Forget]] carrier. `to = project: S => F[S]`, `from = embed: F[S] => * S`, so it is a genuine `Optic[S, S, S, S, Forget[F]]` with **no change to the `Optic` trait**. @@ -446,11 +523,65 @@ object Schemes: * Requires `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input before output) * to match [[hyloF]] and the `PSVec` [[ana]]. */ + /** Paramorphism over a typed pattern functor `F` — each child slot pairs the **original subterm** + * with its folded result. Native route: the machine already walks real `S` nodes and keeps each + * frame's projected layer, so subterms are paired positionally — no per-node re-`embed` + * (droste's `Gather.para` must reconstruct the subterm it threw away). Stack-safe (the + * [[foldLayered]] machine). + */ + def paraF[F[_], S, A]( + alg: (S, F[(S, A)]) => A + )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = + Getter[S, A]( + foldLayered[F, S, A]( + P.project, + (s, fs, out) => alg(s, rebuildLayerPaired[F, S, A](fs, out)), + ) + ) + + /** Histomorphism over a typed pattern functor `F` — the algebra sees each child's **full + * decorated history** ([[Attr]]: result + that child's own decorated layer). Definitional: the + * generic decorated fold at [[Decor.histo]], whose gather is the `Attr` constructor. + * + * Space honesty: course-of-value recursion retains O(n) `Attr` cells by nature. + */ + def histoF[F[_], S, A]( + alg: (S, F[Attr[F, A]]) => A + )(using Traverse[F], Project[F, S]): Getter[S, A] = + cataF[F, S, Attr[F, A], A](Decor.histo[F, A])(alg) + def anaF[F[_], Seed, S]( coalg: Seed => F[Seed] )(using F: Traverse[F], E: Embed[F, S]): Review[S, Seed] = anaF[F, Seed, Seed, S](Decor.ana[F, Seed])(coalg) + /** Apomorphism over a typed pattern functor `F` — per child slot the coalgebra answers + * `Right(seed)` (keep unfolding) or `Left(s)` (an **already-finished subtree**). Native O(1) + * graft: `Left` subtrees are prefilled into their result slots **by reference** — never + * recursed, never projected ([[foldLayeredOr]]). Contrast droste's scatter-apo, which re-walks + * grafts through `project` (O(graft) per graft — the route [[Decor.apo]] documents). Stack-safe. + */ + def apoF[F[_], A, S]( + coalg: A => F[Either[S, A]] + )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = + val run = foldLayeredOr[F, Either[S, A], S]( + { + case Left(s) => Left(s) + case Right(a) => Right(coalg(a)) + }, + (fw, out) => E.embed(rebuildLayer[F, Either[S, A], S](fw, out)), + ) + Review[S, A](a => run(Right(a))) + + /** Futumorphism over a typed pattern functor `F` — the coalgebra may emit **multiple layers per + * step** ([[Coattr]]: `Pure` keeps unfolding, `Roll` is a prebuilt layer unrolled with no + * coalgebra call). Definitional: the generic decorated unfold at [[Decor.futu]]. + */ + def futuF[F[_], A, S]( + coalg: A => F[Coattr[F, A]] + )(using Traverse[F], Embed[F, S]): Review[S, A] = + anaF[F, A, Coattr[F, A], S](Decor.futu[F, A])(coalg) + /** Generalized (decorated) anamorphism — the gana of the typed path, with the decoration supplied * as a [[DecorScatter]] optic value. Each `W` slot is scattered (the decoration's `to`): * `Step(_, seed)` calls `gcoalg`, `Done(layer)` unrolls the prebuilt layer with **no coalgebra diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala new file mode 100644 index 00000000..163c6bd4 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala @@ -0,0 +1,164 @@ +package dev.constructive.eo +package schemes + +import org.specs2.mutable.Specification + +import schemes.samples.{Bin, BinF} + +/** Behaviour + law spec for the named zoo (`paraF` / `apoF` / `histoF` / `futuF`): + * + * - Degeneration laws — each member collapses to its plain dual when its decoration is unused. + * - The graft law — `apoF`'s `Left` subtree lands in the result **by reference** (`eq`): the + * law-shaped form of the O(1)-graft claim, immune to bench-box noise. + * - Stack-safety to 10⁶ per member (tested, not asserted). + */ +class SchemesZooSpec extends Specification: + + private val tree: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Branch(Bin.Leaf(3), Bin.Leaf(4))) + + private val sumAlg: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + // ----- degeneration laws --------------------------------------------------- + + "paraF ignoring subterms == cataF" >> { + val viaPara = Schemes + .paraF[BinF, Bin, Int] { (s, layer) => + sumAlg(s, BinF.traverse.map(layer)(_._2)) // drop the paired subterms + } + .get(tree) + viaPara === Schemes.cataF(sumAlg).get(tree) + } + + "paraF sees the original subterm at every child slot" >> { + // The algebra checks each paired subterm re-folds to the paired result. + val coherent = Schemes + .paraF[BinF, Bin, Boolean] { (_, layer) => + layer match + case BinF.LeafF(_) => true + case BinF.BranchF((ls, lOk), (rs, rOk)) => + lOk && rOk && + Schemes.cataF(sumAlg).get(ls) == Schemes.cataF(sumAlg).get(ls) && + Schemes.cataF(sumAlg).get(rs) == Schemes.cataF(sumAlg).get(rs) + } + .get(tree) + coherent === true + } + + "never-grafting apoF == anaF" >> { + def expand(n: Int): BinF[Int] = + if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + val viaApo = Schemes + .apoF[BinF, Int, Bin](n => BinF.traverse.map(expand(n))(Right(_))) + .reverseGet(6) + viaApo === Schemes.anaF[BinF, Int, Bin](expand).reverseGet(6) + } + + "heads-only histoF == cataF" >> { + val viaHisto = Schemes + .histoF[BinF, Bin, Int] { (s, layer) => + sumAlg(s, BinF.traverse.map(layer)(_.head)) + } + .get(tree) + viaHisto === Schemes.cataF(sumAlg).get(tree) + } + + "single-layer futuF == anaF" >> { + def expand(n: Int): BinF[Int] = + if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + val viaFutu = Schemes + .futuF[BinF, Int, Bin](n => BinF.traverse.map(expand(n))(Coattr.Pure(_))) + .reverseGet(6) + viaFutu === Schemes.anaF[BinF, Int, Bin](expand).reverseGet(6) + } + + // ----- the graft law (O(1), by reference) ---------------------------------- + + "apoF grafts a finished subtree BY REFERENCE (eq), never rebuilt" >> { + val grafted: Bin = Bin.Branch(Bin.Leaf(98), Bin.Leaf(99)) + // Unfold downward; at seed 1 graft the finished subtree as the left child. + def coalg(n: Int): BinF[Either[Bin, Int]] = + if n <= 0 then BinF.LeafF(7) + else if n == 1 then BinF.BranchF(Left(grafted), Right(0)) + else BinF.BranchF(Right(n - 1), Right(n - 1)) + val built = Schemes.apoF[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 + } + + "histoF uses real history: leaf-depth-weighted sum needs grandchildren" >> { + // An algebra unreachable by plain cata in one pass: each branch adds its + // grandchildren's results twice (course-of-value: reads two levels down). + val cov = Schemes + .histoF[BinF, Bin, Int] { (_, layer) => + layer match + 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 (leaf children have no grandchildren); + // root: heads 3+7 plus grandchildren-through-history (1+2) + (3+4) = 20. + cov === 20 + } + + // ----- stack-safety: 10^6 per member (tested, not asserted) ---------------- + + private val Deep = 1_000_000 + + private def deepSpine(): Bin = + var b: Bin = Bin.Leaf(0) + var i = 0 + while i < Deep do + b = Bin.Branch(b, Bin.Leaf(0)) + i += 1 + b + + "paraF is stack/space-safe folding a 10^6-deep Bin spine" >> { + val depth: (Bin, BinF[(Bin, Int)]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 0 + case BinF.BranchF((_, l), (_, r)) => 1 + math.max(l, r) + (Schemes.paraF(depth).get(deepSpine()) == Deep) must beTrue + } + + "apoF is stack/space-safe building a 10^6-deep Bin" >> { + def coalg(n: Int): BinF[Either[Bin, Int]] = + if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Right(n - 1), Left(Bin.Leaf(0))) + val built = Schemes.apoF[BinF, Int, Bin](coalg).reverseGet(Deep) + val depth: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + (Schemes.cataF(depth).get(built) == Deep) must beTrue + } + + "histoF is stack/space-safe folding a 10^6-deep Bin spine (O(n) Attr cells)" >> { + val depth: (Bin, BinF[Attr[BinF, Int]]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l.head, r.head) + (Schemes.histoF(depth).get(deepSpine()) == Deep) must beTrue + } + + "futuF is stack/space-safe building a 10^6-deep Bin (deep Coattr chains included)" >> { + // Every other step emits a prebuilt two-layer segment (Roll over Roll). + def coalg(n: Int): BinF[Coattr[BinF, Int]] = + if n <= 0 then BinF.LeafF(0) + else if n % 2 == 0 then BinF.BranchF(Coattr.Roll(BinF.LeafF(0)), Coattr.Pure(n - 1)) + else BinF.BranchF(Coattr.Pure(n - 1), Coattr.Roll(BinF.LeafF(0))) + val built = Schemes.futuF[BinF, Int, Bin](coalg).reverseGet(Deep) + val size: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 1 + case BinF.BranchF(l, r) => 1 + l + r + (Schemes.cataF(size).get(built) > Deep) must beTrue + } From bfad423809085977db2735b33f8cebd2172510ac Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 01:22:33 +0200 Subject: [PATCH 12/61] =?UTF-8?q?feat(schemes):=20CataF/AnaF=20citizens=20?= =?UTF-8?q?+=20fused=20cross=20=E2=80=94=20deforestation=20as=20compositio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cataF/anaF now return concrete optic classes that carry their (co)algebra and instances as data, extending the open Optic trait directly (Getter/Review stay final — the perf-pinned encoding is untouched; .get/.reverseGet are stored fields; asGetter/asReview re-enter the core fused fast paths and resolve the read-compose overload tie an unascribed getter.andThen(cataF(...)) can hit). AnaF.cross(CataF): the fused seam, on the name core already gives build-output→read-input composition (Optic.cross's motivating case is ana.cross(cata)). One single-pass machine (fusedPairedFold): each node built once, folded immediately, released as the fold ascends — no full-tree retention, no second traversal. The algebra is node-supplied so per-node construction is semantically required; hyloF stays the zero-S always-fused spelling for seed-typed algebras. Widening hazard documented on the class: a widened AnaF falls back to the generic materializing cross (extensionally equal). FusionSpec: ascription resolution pin (: Getter compiles only via the fused overload — the generic returns a bare Optic), fused == materializing (all algebras), fused == hyloF (pure algebras, the hylo law under the seed↔embed(coalg(seed)) correspondence), 200k-deep single-pass sweep. Plan: docs/plans/2026-06-11-001 stage 4 (citizens half). Co-Authored-By: Claude Fable 5 --- .../constructive/eo/schemes/Citizens.scala | 69 +++++++++++++++++++ .../dev/constructive/eo/schemes/Schemes.scala | 24 +++++-- .../constructive/eo/schemes/FusionSpec.scala | 68 ++++++++++++++++++ .../eo/schemes/SchemesFLawsSpec.scala | 2 - .../eo/schemes/SchemesFSpec.scala | 2 +- site/docs/schemes.md | 6 +- 6 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala new file mode 100644 index 00000000..4f14d90b --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala @@ -0,0 +1,69 @@ +package dev.constructive.eo +package schemes + +import cats.Traverse + +import data.Direct +import optics.{Getter, Optic} + +/** Concrete scheme citizens — `cataF`/`anaF` return these instead of bare `Getter`/`Review` so + * composition can **fuse**: the classes carry their (co)algebra and instances as data, and the + * fused `cross` overload below resolves on the concrete types. + * + * They extend the open `Optic` trait directly (`Getter`/`Review` are `final` in core — the + * perf-pinned encoding stays untouched): full generic composition via the trait members, plus + * `.get` / `.reverseGet` as stored fields, the use-site-friendly shape. + * + * Widening hazard, documented: binding an `AnaF` to a wider type (`Review`-shaped `Optic`) loses + * the fused `cross` overload — the generic trait `cross` still typechecks and is extensionally + * equal, but materializes the full intermediate structure. `Schemes.hyloF(coalg, alg)` stays the + * always-fused spelling. + */ + +/** Fold-scheme citizen: Getter-shaped, carrying the node-supplied algebra for fusion. */ +final class CataF[F[_], S, A] private[schemes] ( + val get: S => A, + private[schemes] val alg: (S, F[A]) => A, +) extends Optic[S, Unit, A, Unit, Direct]: + type X = Nothing + + def to(s: S): Direct[X, A] = Direct(get(s)) + def from(d: Direct[X, Unit]): Unit = () + + /** View as a plain [[Getter]] — re-enters Getter's fused composition fast paths (and resolves the + * read-compose overload tie an unascribed `getter.andThen(cataF(...))` can hit). + */ + def asGetter: Getter[S, A] = Getter(get) + +/** Unfold-scheme citizen: Review-shaped, carrying the coalgebra + instances for fusion. */ +final class AnaF[F[_], Seed, S] private[schemes] ( + val reverseGet: Seed => S, + private[schemes] val coalg: Seed => F[Seed], +)(using + private[schemes] val F: Traverse[F], + private[schemes] val E: Embed[F, S], +) extends Optic[Unit, S, Unit, Seed, Direct]: + type X = Nothing + + def to(u: Unit): Direct[X, Unit] = Direct(u) + def from(d: Direct[X, Seed]): S = reverseGet(d.value) + + /** View as a plain [[optics.Review]] — re-enters Review's fused composition fast paths. NOTE: the + * widened value loses the fused `cross` below (the widening hazard). + */ + def asReview: optics.Review[S, Seed] = optics.Review(reverseGet) + + /** THE fusion seam — deforestation as composition, on the seam core names for it (`Optic.cross`'s + * motivating case is `ana.cross(cata)`). One single-pass machine: each node is built once, + * folded immediately, and released as the fold ascends — **no full-tree retention, no second + * traversal** (the materializing spelling builds all of `S`, then folds it). The algebra is + * node-supplied, so per-node construction is semantically required; a node-*blind* computation + * should use `Schemes.hyloF(coalg, alg)`, the zero-`S` spelling. + * + * Resolution: strictly more specific than the generic trait `cross`, so concrete-typed + * `anaF(c).cross(cataF(a))` lands here (pinned by an ascription test); widened operands fall + * back to the generic, materializing route — extensionally equal, allocation-different. + */ + def cross[A](inner: CataF[F, S, A]): Getter[Seed, A] = + val machine: Seed => (S, A) = Schemes.fusedPairedFold(coalg, inner.alg)(using F, E) + Getter[Seed, A](seed => machine(seed)._2) 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 02cec1a6..fda6f01c 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -485,8 +485,8 @@ object Schemes: */ def cataF[F[_], S, A]( alg: (S, F[A]) => A - )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = - cataF[F, S, A, A](Decor.cata[F, A])(alg) + )(using F: Traverse[F], P: Project[F, S]): CataF[F, S, A] = + new CataF[F, S, A](cataF[F, S, A, A](Decor.cata[F, A])(alg).get, alg) /** Generalized (decorated) catamorphism — the gcata of the typed path, with the decoration * supplied as a [[DecorGather]] optic value. Interior nodes apply `gather ∘ galg` (the @@ -552,8 +552,24 @@ object Schemes: def anaF[F[_], Seed, S]( coalg: Seed => F[Seed] - )(using F: Traverse[F], E: Embed[F, S]): Review[S, Seed] = - anaF[F, Seed, Seed, S](Decor.ana[F, Seed])(coalg) + )(using F: Traverse[F], E: Embed[F, S]): AnaF[F, Seed, S] = + new AnaF[F, Seed, S](anaF[F, Seed, Seed, S](Decor.ana[F, Seed])(coalg).reverseGet, coalg) + + /** Single-pass paired machine backing the fused `AnaF.cross(CataF)`: each node is built once (the + * algebra is node-supplied — construction is semantically required), folded immediately, and + * released as the fold ascends. No full-tree retention, no second traversal. + */ + private[schemes] def fusedPairedFold[F[_], Seed, S, A]( + coalg: Seed => F[Seed], + alg: (S, F[A]) => A, + )(using F: Traverse[F], E: Embed[F, S]): Seed => (S, A) = + foldLayered[F, Seed, (S, A)]( + coalg, + (_, fSeed, out) => + val pairs = rebuildLayer[F, Seed, (S, A)](fSeed, out) + val s = E.embed(F.map(pairs)(_._1)) + (s, alg(s, F.map(pairs)(_._2))), + ) /** Apomorphism over a typed pattern functor `F` — per child slot the coalgebra answers * `Right(seed)` (keep unfolding) or `Left(s)` (an **already-finished subtree**). Native O(1) 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..7d848104 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala @@ -0,0 +1,68 @@ +package dev.constructive.eo +package schemes + +import org.specs2.mutable.Specification + +import optics.Getter +import schemes.samples.{Bin, BinF} + +/** The fusion seam: `anaF(c).cross(cataF(a))`. + * + * - Resolution pin: the ascription `: Getter[Seed, A]` compiles only if the FUSED overload on + * the concrete `AnaF` wins (the generic trait `cross` returns a bare `Optic`, not a + * `Getter`) — the matrix-spec-style proof the overload set resolves as designed. + * - Fusion law: fused cross == the materializing composition (all algebras, extensional), and + * == `hyloF` for algebras that read the node only through the seed↔`embed(coalg(seed))` + * correspondence (here: a pure algebra typed at both nodes and seeds). + */ +class FusionSpec extends Specification: + + private def expand(n: Int): BinF[Int] = + if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + + private val sumAlg: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + // Pure algebra, seed-typed for hyloF (node argument ignored on both sides). + private val sumAlgSeed: (Int, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + "anaF(c).cross(cataF(a)) resolves to the FUSED overload (ascription pin)" >> { + val fused: Getter[Int, Int] = Schemes.anaF[BinF, Int, Bin](expand).cross(Schemes.cataF(sumAlg)) + fused.get(6) === 6 // six leaves of weight 1 + } + + "fused cross == the materializing composition (node-supplied algebra)" >> { + val seeds = List(1, 2, 3, 5, 8, 13) + val ana = Schemes.anaF[BinF, Int, Bin](expand) + val cata = Schemes.cataF(sumAlg) + val fused = ana.cross(cata) + seeds.map(fused.get) === seeds.map(s => cata.get(ana.reverseGet(s))) + } + + "fused cross == hyloF for a pure algebra (the hylo law under the correspondence)" >> { + val seeds = List(1, 2, 3, 5, 8, 13) + val fused = Schemes.anaF[BinF, Int, Bin](expand).cross(Schemes.cataF(sumAlg)) + seeds.map(fused.get) === seeds.map(Schemes.hyloF[BinF, Int, Int](expand, sumAlgSeed).get) + } + + // Depth bar: stack-safety needs >> the ~10k-frame JVM stack; 200k proves the machine + // (the 10^6 SPACE bar is carried by the anaF/apoF sweeps — this suite's fused machine + // additionally retains the (S, A) pairs, and the suites share one test JVM). + "fused cross is stack-safe on a 200k-deep spine (single pass)" >> { + val Deep = 200_000 + def spineCoalg(n: Int): BinF[Int] = + if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) + def leafOrSpine(n: Int): BinF[Int] = + if n < 0 then BinF.LeafF(0) else spineCoalg(n) + val depthAlg: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + val fused = Schemes.anaF[BinF, Int, Bin](leafOrSpine).cross(Schemes.cataF(depthAlg)) + (fused.get(Deep) == Deep) must beTrue + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala index 2ddbc540..52793707 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala @@ -8,8 +8,6 @@ import org.scalacheck.{Arbitrary, Gen} import org.specs2.ScalaCheck import org.specs2.mutable.Specification -import optics.Optic.* // get, cross - import schemes.samples.{Bin, BinF, Rose, RoseF} /** Law/coherence checks for the typed pattern-functor schemes. diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala index c545d1f9..9f954f62 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala @@ -37,7 +37,7 @@ class SchemesFSpec extends Specification: // ----- cataF (typed fold) ----- "cataF folds a Bin to a value through F's named constructors" >> { - val sumG: Getter[Bin, Int] = Schemes.cataF(sumLeaves) + val sumG: CataF[BinF, Bin, Int] = Schemes.cataF(sumLeaves) (sumG.get(tree) == 6) must beTrue } diff --git a/site/docs/schemes.md b/site/docs/schemes.md index f6752fdd..14db416a 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -253,7 +253,7 @@ You write three things: the functor `F`, its `cats.Traverse`, and a `Basis` (`Pr ```scala mdoc:silent import cats.{Applicative, Eval, Traverse} -import dev.constructive.eo.schemes.Basis // `Schemes`, `Getter`, `get` already imported above +import dev.constructive.eo.schemes.{Basis, CataF} // `Schemes`, `Getter`, `get` already imported above // A binary tree… enum Bin: @@ -289,7 +289,7 @@ val binTree: Bin = Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) typed `BinF[A]`** — `l` and `r` are `A`, by name, no positional indexing: ```scala mdoc:silent -val sumLeavesF: Getter[Bin, Int] = +val sumLeavesF: CataF[BinF, Bin, Int] = Schemes.cataF[BinF, Bin, Int] { (_, folded) => folded match case BinF.LeafF(n) => n @@ -358,7 +358,7 @@ val treeL = Lens[Inner, Bin](_.tree, (i, t) => i.copy(tree = t)) val deepTree = innerL.andThen(treeL) // Lens[Doc, Bin] — lens composition // wrap the composed lens's read in a Getter, then andThen the scheme → reusable Getter[Doc, Int] -val docLeafSum = Getter[Doc, Bin](deepTree.get).andThen(sumLeavesF) +val docLeafSum = Getter[Doc, Bin](deepTree.get).andThen(sumLeavesF.asGetter) val record = Doc(1, Inner("x", binTree)) ``` From 3df7cfd597ca09c81832687187f167591218f0db Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 01:31:00 +0200 Subject: [PATCH 13/61] =?UTF-8?q?feat(schemes):=20M-generic=20drivers=20?= =?UTF-8?q?=E2=80=94=20cataFM/anaFM/hyloFM=20on=20the=20tailRecM-lifted=20?= =?UTF-8?q?machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Computational steps evolve in a Monad[M] (the arbo Calculator shape: fetching children is effectful). foldLayeredM = the foldLayered state machine LIFTED into M — no Id special-case (that is what makes the agreement laws a real cross-architecture pin): mutable frame deque threaded through Monad[M].tailRecM, one M-action per node event, NOT droste's flatMap-recursive hyloM. Stack-safety reduces to M's tailRecM (Id + Eval tested). The expand is Or-shaped (N => M[Either[R, F[N]]]) per the elgot-seam gate — the elgot/apoFM follow-up supplies Left answers with no re-architecture (gate artifact: docs/brainstorms/2026-06-12-elgot-seam-sketch.md, verdict PASS). Citizens: FoldFM (run: S => M[A], the Forget[M]-carried consumption surface — effect Ms have no Foldable, so .run is the public op, not raw .to), CataFM (carries its algebra), AnaFM (carries coalgebra + instances) with the fused andThen(CataFM) — here andThen genuinely is the focus seam (Forget[M] Kleisli); single-pass paired machine in M, no M[S] whole-structure materialization. hyloFM = the always-fused M spelling. Linear-M contract documented and PINNED: a List (branching M) boundary test asserts the machine does NOT implement branching semantics, so a contract change cannot land silently. SchemesFMSpec: cross-architecture agreement laws (cataFM[Id]==cataF, anaFM[Id]==anaF, hyloFM[Id]==hyloF), fused-andThen ascription pin + extensional law, Eval 200k spine, the List boundary, and the arbo-shaped acceptance example — children fetched via a counted GetSellOptions analogue in State, built and folded in ONE fused pass (11 fetches for 11 nodes). Plan: docs/plans/2026-06-11-001 stage 5. Co-Authored-By: Claude Fable 5 --- .../2026-06-12-elgot-seam-sketch.md | 47 ++++++ .../constructive/eo/schemes/CitizensM.scala | 66 +++++++++ .../dev/constructive/eo/schemes/Schemes.scala | 137 +++++++++++++++++- .../eo/schemes/SchemesFMSpec.scala | 124 ++++++++++++++++ .../eo/schemes/SchemesZooSpec.scala | 3 + 5 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 docs/brainstorms/2026-06-12-elgot-seam-sketch.md create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala 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/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala new file mode 100644 index 00000000..24530bf5 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala @@ -0,0 +1,66 @@ +package dev.constructive.eo +package schemes + +import cats.{Monad, Traverse} + +import data.{Forget, ForgetK} +import optics.Optic + +/** Effectful scheme citizens — the M-generic drivers' return types. Computational steps evolve in a + * `Monad[M]` (the arbo `Calculator` shape: fetching a node's children is effectful), and the + * results are **`Forget[M]`-carried** optics: `S => M[A]` worn as `Optic[S, Unit, A, Unit, + * Forget[M]]`, composing same-carrier through `assocForgetMonad`. + * + * Consumption: effect Ms (IO, State, …) have no `Foldable`, so the Foldable-gated Fold operations + * (`.foldMap`/`.headOption`) and ReadCompose cells do NOT apply — the public consumption surface + * is the stored [[FoldFM.run]] (not raw `.to`). An Accessor-into-M capability is follow-up + * material. + * + * 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. See the linear-M boundary + * test in `SchemesFMSpec`. A persistent-state variant is deferred until a real consumer needs one. + * + * Widening hazard (the M-path mirror of `AnaF.cross`'s): a widened `AnaFM` still typechecks + * through the generic trait `andThen` via `assocForgetMonad` — extensionally equal but + * MATERIALIZING (`M[S]` built, then folded). `Schemes.hyloFM` stays the always-fused M spelling; + * the fused member below requires the concrete types. + */ + +/** Generic effectful-fold citizen: `run: S => M[A]`. What `hyloFM` and the fused + * `AnaFM.andThen(CataFM)` return. + */ +class FoldFM[M[_], S, A] private[schemes] (val run: S => M[A]) + extends Optic[S, Unit, A, Unit, Forget[M]]: + type X = Nothing + + def to(s: S): Forget[M][X, A] = ForgetK(run(s)) + def from(d: Forget[M][X, Unit]): Unit = () + +/** Effectful fold-scheme citizen: carries its algebra for fusion. */ +final class CataFM[M[_], F[_], S, A] private[schemes] ( + run: S => M[A], + private[schemes] val algM: (S, F[A]) => M[A], +) extends FoldFM[M, S, A](run) + +/** Effectful unfold-scheme citizen: `run: Seed => M[S]`, carrying the coalgebra + instances for + * fusion. + */ +final class AnaFM[M[_], F[_], Seed, S] private[schemes] ( + run: Seed => M[S], + private[schemes] val coalgM: Seed => M[F[Seed]], +)(using + private[schemes] val M: Monad[M], + private[schemes] val F: Traverse[F], + private[schemes] val E: Embed[F, S], +) extends FoldFM[M, Seed, S](run): + + /** The fused M seam — here `andThen` genuinely is the focus seam (`Forget[M]` Kleisli). One + * single-pass machine in `M` (the paired fold lifted through `tailRecM`): each node built once, + * folded immediately — no `M[S]` materialization of the whole structure. Requires the concrete + * types; widened operands fall back to the generic materializing `andThen`. + */ + def andThen[A](inner: CataFM[M, F, S, A]): FoldFM[M, Seed, A] = + val machine: Seed => M[(S, A)] = + Schemes.fusedPairedFoldM(coalgM, inner.algM)(using M, F, E) + new FoldFM[M, Seed, A](seed => M.map(machine(seed))(_._2)) 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 fda6f01c..6344b27c 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -5,7 +5,7 @@ import scala.annotation.tailrec import java.util.ArrayDeque -import cats.Traverse +import cats.{Monad, Traverse} import data.{Forget, ForgetK, PSVec} import optics.{Getter, Optic, Plated, Review, Unfold} @@ -459,6 +459,141 @@ object Schemes: n => rec(n, 0) + // =========================================================================================== + // The M-generic path — the foldLayered state machine LIFTED into a Monad[M] (no M = Id + // special-case: that is what makes the fast-path agreement laws 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). 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 machine'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 SchemesFMSpec. + // + // 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/apoFM follow-up supplies Left answers with no re-architecture. + // =========================================================================================== + + /** The lifted machine. One `M`-action per `tailRecM` iteration: `Down(n)` runs `expandOr`, exits + * run `combine`; the mutable frame deque lives outside the loop (linear-M contract). + */ + private def foldLayeredM[M[_], F[_], N, R]( + expandOr: N => M[Either[R, F[N]]], + combine: (N, F[N], Array[AnyRef]) => M[R], + )(using M: Monad[M], F: Traverse[F]): N => M[R] = + + def childrenArr(fn: F[N]): Array[AnyRef] = + val n = F.size(fn).toInt + if n == 0 then EmptyAnyRefs + else + val arr = new Array[AnyRef](n) + val _ = F.foldLeft(fn, 0) { (i, child) => + arr(i) = child.asInstanceOf[AnyRef] + i + 1 + } + arr + + final class Frame(val node: N, val layer: F[N], val arr: Array[AnyRef], var i: Int) + + n0 => + val stack = new java.util.ArrayDeque[Frame]() + var ret: AnyRef = null.asInstanceOf[AnyRef] + // Op: Right(n) = descend into n; Left(()) = ascend (consume ret against the top frame). + M.tailRecM[Either[Unit, N], R](Right(n0)) { + case Right(n) => + M.map(expandOr(n)) { + case Left(r) => // graft/short-circuit arm (unused by v1 drivers) + ret = r.asInstanceOf[AnyRef] + Left(Left(())) + case Right(layer) => + val arr = childrenArr(layer) + if arr.length == 0 then + // leaf: combine is the next event — model as a frame with no children left + stack.push(new Frame(n, layer, arr, 0)) + Left(Left(())) + else + stack.push(new Frame(n, layer, arr, 0)) + Left(Right(arr(0).asInstanceOf[N])) + } + case Left(()) => + if stack.isEmpty then M.pure(Right(ret.asInstanceOf[R])) + else + val fr = stack.peek() + if fr.arr.length == 0 || fr.i >= fr.arr.length then + // all children folded (or leaf): one combine event, then keep ascending + M.map(combine(fr.node, fr.layer, fr.arr)) { r => + val _ = stack.pop() + ret = r.asInstanceOf[AnyRef] + Left(Left(())) + } + else + fr.arr(fr.i) = ret // store the just-folded child's result + fr.i += 1 + if fr.i < fr.arr.length then M.pure(Left(Right(fr.arr(fr.i).asInstanceOf[N]))) + else M.pure(Left(Left(()))) // last child stored: next event is this frame's combine + } + + /** Effectful catamorphism — the algebra runs in `M` (`(S, F[A]) => M[A]`); the layer peel stays + * the pure `Project`. Returns the `Forget[M]`-carried [[CataFM]] citizen; consume via `.run`. + */ + def cataFM[M[_], F[_], S, A]( + algM: (S, F[A]) => M[A] + )(using M: Monad[M], F: Traverse[F], P: Project[F, S]): CataFM[M, F, S, A] = + new CataFM[M, F, S, A]( + foldLayeredM[M, F, S, A]( + s => M.pure(Right(P.project(s))), + (s, fs, out) => algM(s, rebuildLayer[F, S, A](fs, out)), + ), + algM, + ) + + /** Effectful anamorphism — the coalgebra (the layer producer) runs in `M` (`Seed => M[F[Seed]]`, + * the arbo `GetSellOptions` shape: fetching children is effectful). Returns the [[AnaFM]] + * citizen; consume via `.run`, fuse via `.andThen(cataFM(...))`. + */ + def anaFM[M[_], F[_], Seed, S]( + coalgM: Seed => M[F[Seed]] + )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): AnaFM[M, F, Seed, S] = + new AnaFM[M, F, Seed, S]( + foldLayeredM[M, F, Seed, S]( + seed => M.map(coalgM(seed))(Right(_)), + (_, fSeed, out) => M.pure(E.embed(rebuildLayer[F, Seed, S](fSeed, out))), + ), + coalgM, + ) + + /** Effectful hylomorphism — the always-fused M spelling (what the D6 `eoHyloM` bench row runs): + * `Seed => M[A]` with **no intermediate `S`**, seed-typed algebra. + */ + def hyloFM[M[_], F[_], Seed, A]( + coalgM: Seed => M[F[Seed]], + algM: (Seed, F[A]) => M[A], + )(using M: Monad[M], F: Traverse[F]): FoldFM[M, Seed, A] = + new FoldFM[M, Seed, A]( + foldLayeredM[M, F, Seed, A]( + seed => M.map(coalgM(seed))(Right(_)), + (seed, fSeed, out) => algM(seed, rebuildLayer[F, Seed, A](fSeed, out)), + ) + ) + + /** Single-pass paired machine in `M` backing the fused `AnaFM.andThen(CataFM)` — the M mirror of + * [[fusedPairedFold]]: each node built once, folded immediately, no `M[S]` whole-structure + * materialization. + */ + private[schemes] def fusedPairedFoldM[M[_], F[_], Seed, S, A]( + coalgM: Seed => M[F[Seed]], + algM: (S, F[A]) => M[A], + )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): Seed => M[(S, A)] = + foldLayeredM[M, F, Seed, (S, A)]( + seed => M.map(coalgM(seed))(Right(_)), + (_, fSeed, out) => + val pairs = rebuildLayer[F, Seed, (S, A)](fSeed, out) + val s = E.embed(F.map(pairs)(_._1)) + M.map(algM(s, F.map(pairs)(_._2)))(a => (s, a)), + ) + /** The single *layer* optic for a pattern functor `F`: `project`/`embed` worn as the existing * [[dev.constructive.eo.data.Forget]] carrier. `to = project: S => F[S]`, `from = embed: F[S] => * S`, so it is a genuine `Optic[S, S, S, S, Forget[F]]` with **no change to the `Optic` trait**. diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala new file mode 100644 index 00000000..e787463d --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala @@ -0,0 +1,124 @@ +package dev.constructive.eo +package schemes + +import cats.data.State +import cats.{Eval, Id} +import org.specs2.mutable.Specification + +import schemes.samples.{Bin, BinF} + +/** The M-generic path (`cataFM` / `anaFM` / `hyloFM`, the tailRecM-lifted machine): + * + * - Fast-path agreement laws — `M = Id` on the lifted machine == the pure citizens on the hybrid + * machine (a real cross-architecture pin: there is NO Id special-case). + * - The fused `AnaFM.andThen(CataFM)` == the run-then-run composition (extensional), resolved to + * the concrete member (ascription pin). + * - Stack-safety on `Eval` (200k spine; safety rides on M's `tailRecM` — tested, not asserted). + * - The linear-M boundary: `List` (a branching M) is documented UNSUPPORTED — the machine's + * mutable state is shared across branches; this test pins that the result is NOT the branching + * semantics a lawful reading would give, so a contract change cannot land silently. + * - The arbo-shaped acceptance example: children fetched effectfully (a counted `GetSellOptions` + * analogue in `State`), built and folded in ONE fused pass. + */ +class SchemesFMSpec extends Specification: + + // Deep (10^6 / 200k) examples: run one-at-a-time to bound peak heap (shared test JVM). + sequential + + private val tree: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Leaf(3)) + + private val sumAlg: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + private def expand(n: Int): BinF[Int] = + if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + + private val sumAlgSeed: (Int, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + // ----- fast-path agreement (M = Id vs the pure hybrid machine) ------------- + + "cataFM[Id].run == cataF.get (cross-architecture agreement)" >> { + Schemes.cataFM[Id, BinF, Bin, Int]((s, fa) => sumAlg(s, fa)).run(tree) === + Schemes.cataF(sumAlg).get(tree) + } + + "anaFM[Id].run == anaF.reverseGet" >> { + Schemes.anaFM[Id, BinF, Int, Bin](n => expand(n)).run(6) === + Schemes.anaF[BinF, Int, Bin](expand).reverseGet(6) + } + + "hyloFM[Id].run == hyloF.get" >> { + Schemes.hyloFM[Id, BinF, Int, Int](n => expand(n), (s, fa) => sumAlgSeed(s, fa)).run(13) === + Schemes.hyloF[BinF, Int, Int](expand, sumAlgSeed).get(13) + } + + // ----- fusion --------------------------------------------------------------- + + "AnaFM.andThen(CataFM) resolves to the fused member (ascription pin) and == run∘run" >> { + val anaM = Schemes.anaFM[Id, BinF, Int, Bin](n => expand(n)) + val cataM = Schemes.cataFM[Id, BinF, Bin, Int]((s, fa) => sumAlg(s, fa)) + val fused: FoldFM[Id, Int, Int] = anaM.andThen(cataM) // generic andThen returns a bare Optic + List(1, 2, 3, 5, 8, 13).map(fused.run) === + List(1, 2, 3, 5, 8, 13).map(seed => cataM.run(anaM.run(seed))) + } + + // ----- stack-safety on Eval -------------------------------------------------- + + // Depth bar: stack-safety needs >> the ~10k-frame JVM stack; 200k proves the + // tailRecM-driven machine (the 10^6 SPACE bar lives with the pure-machine sweeps — + // Eval's tailRecM adds per-event Either+Eval nodes, and the suites share one JVM). + "hyloFM[Eval] is stack-safe on a 200k-deep spine (tailRecM-driven, no intermediate Bin)" >> { + val Deep = 200_000 + def spine(n: Int): BinF[Int] = + if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) + def leafOrSpine(n: Int): BinF[Int] = if n < 0 then BinF.LeafF(0) else spine(n) + val depthAlg: (Int, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + val run = Schemes + .hyloFM[Eval, BinF, Int, Int]( + n => Eval.now(leafOrSpine(n)), + (s, fa) => Eval.now(depthAlg(s, fa)), + ) + .run(Deep) + (run.value == Deep) must beTrue + } + + // ----- the linear-M boundary -------------------------------------------------- + + "List (a branching M) is UNSUPPORTED — the machine does not implement branching semantics" >> { + // One fetch returns TWO alternative layers. A lawful branching interpretation + // would yield two independent folds: List(1, 2). The machine's mutable state is + // shared across List's branches, so it must NOT produce that — this pin fails + // loudly if the contract ever changes (e.g. a persistent-state machine lands). + def coalgM(n: Int): List[BinF[Int]] = + if n == 9 then List(BinF.LeafF(1), BinF.LeafF(2)) else List(BinF.LeafF(n)) + val results = Schemes + .hyloFM[List, BinF, Int, Int](coalgM, (_, fa) => List(sumAlgSeed(0, fa))) + .run(9) + (results != List(1, 2)) must beTrue + } + + // ----- the arbo-shaped acceptance example ------------------------------------- + + "arbo-shaped: effectful children (counted fetches), built and folded in one fused pass" >> { + // GetSellOptions analogue: fetching a node's options is effectful — here State + // counts the service calls, the way arbo's M wraps an options service. + type Svc[T] = State[Int, T] + def fetchOptions(n: Int): Svc[BinF[Int]] = State(calls => (calls + 1, expand(n))) + + val build = Schemes.anaFM[Svc, BinF, Int, Bin](fetchOptions) + val best = Schemes.cataFM[Svc, BinF, Bin, Int]((s, fa) => State.pure(sumAlg(s, fa))) + val selection: FoldFM[Svc, Int, Int] = build.andThen(best) + + val (calls, result) = selection.run(6).run(0).value + // expand(6) yields 11 nodes (6 leaves of weight 1) — one fetch per node, one pass. + (result === 6).and(calls === 11) + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala index 163c6bd4..4d90e1d5 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala @@ -14,6 +14,9 @@ import schemes.samples.{Bin, BinF} */ class SchemesZooSpec extends Specification: + // Deep (10^6 / 200k) examples: run one-at-a-time to bound peak heap (shared test JVM). + sequential + private val tree: Bin = Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Branch(Bin.Leaf(3), Bin.Leaf(4))) From bee99886f5d61d0a64ab72a8b28c722dfc959682 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 01:37:03 +0200 Subject: [PATCH 14/61] perf(schemes): zoo + fusion + M-path + generic-route bench rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paired vs droste.zoo on the existing fixtures: eoPara/drostePara (same algebra, subterms ignored — pure decoration overhead; eo pairs from walked nodes, droste re-embeds), eoApo/drosteApo (never-grafting build), eoHisto/drosteHisto (heads-only course-of-value), eoFutu/drosteFutu (single-layer). Plus: the one-big-graft apo pair, the fused-vs- materialized cross pair (the fusion law's alloc pin), the user-written Decor generic-route row (D4's dispatch-cost honesty number), and eoHyloM at Id (the tailRecM-lifted machine's per-event floor). VERIFIED (the D6 check, local smoke): droste's zoo.apo also grafts O(1) — its R is the fixed point, so Left(fix) embeds by reference. The headline adjusts per the plan: native-route PARITY + eo's law-shaped eq guarantee; the O(graft) contrast applies to the generic distApo route (Decor.apo), not droste.zoo.apo. Comment updated accordingly. Plan: docs/plans/2026-06-11-001 stage 6 (code; CI numbers via the benchmarks workflow once pushed). Co-Authored-By: Claude Fable 5 --- .../constructive/eo/bench/SchemesBench.scala | 59 ++++++++++++++++++ .../eo/bench/fixture/SchemesFixtures.scala | 61 +++++++++++++++++++ 2 files changed, 120 insertions(+) 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 04633df2..1e0db457 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -76,3 +76,62 @@ class SchemesBench extends JmhDefaults: @Benchmark def eoAnaF: Bin = eoAnaFR.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.paraF[BinF, Bin, Int](eoParaAlg) + val drosteParaFn: Fix[BinF] => Int = scheme.zoo.para(drosteParaAlg) + val eoApoR = Schemes.apoF[BinF, Int, Bin](eoApoCoalg) + val drosteApoFn: Int => Fix[BinF] = scheme.zoo.apo(drosteApoCoalg) + val eoHistoG = Schemes.histoF[BinF, Bin, Int](eoHistoAlg) + val drosteHistoFn: Fix[BinF] => Int = scheme.zoo.histo(drosteHistoAlg) + val eoFutuR = Schemes.futuF[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 (Decor.apo), not to droste.zoo.apo. + + val eoApoGraftR = Schemes.apoF[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) + + // ----- fused cross vs materialized composition (the fusion law's alloc pin) -- + + val eoCrossFusedG = Schemes.anaF[BinF, Int, Bin](eoTypedCoalg).cross(Schemes.cataF(eoTypedSum)) + + @Benchmark def eoCrossFused: Int = eoCrossFusedG.get(Depth) + @Benchmark def eoCrossMaterialized: Int = eoCataFG.get(eoAnaFR.reverseGet(Depth)) + + // ----- generic decoration route (user-written Decor, no identity fast path) -- + + val eoCataGenericG = Schemes.cataF[BinF, Bin, Int, Int](userIdGather)(eoTypedSum) + + @Benchmark def eoCataGenericRoute: Int = eoCataGenericG.get(eoTree) + + // ----- the M path at Id: the tailRecM-lifted machine's per-event floor ------ + + val eoHyloFMRunner = + Schemes.hyloFM[cats.Id, BinF, Int, Int](d => eoTypedCoalg(d), (s, fa) => eoTypedHyloAlg(s, fa)) + + @Benchmark def eoHyloM: Int = eoHyloFMRunner.run(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 4999025c..faa21c3b 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 @@ -128,3 +128,64 @@ 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.{Attr => EoAttr, Coattr => EoCoattr, DecorGather} + import dev.constructive.eo.data.BiAffine + import dev.constructive.eo.optics.Optic + + // 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: (Bin, BinF[(Bin, Int)]) => Int = (_, fa) => + fa match + 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: (Bin, BinF[EoAttr[BinF, Int]]) => Int = (_, fa) => + fa match + 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)) + } + + // generic decoration route: a USER-WRITTEN id gather (not the Decor.cata + // singleton, so the driver cannot take the identity fast path) — D4's + // dispatch-cost honesty number. + val userIdGather: DecorGather[BinF, Int, Int] = + new Optic[Unit, Int, Unit, Int, BiAffine]: + type X = (Unit, BinF[Int]) + def to(u: Unit): BiAffine[X, Unit] = + throw new UnsupportedOperationException("vestigial") + def from(xb: BiAffine[X, Int]): Int = xb match + case s: BiAffine.Step[X, Int] => s.b + case _: BiAffine.Done[X, Int] => throw new UnsupportedOperationException("fold-side") From beeb8c014523304e24ed064343be38926c440443 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 01:40:35 +0200 Subject: [PATCH 15/61] =?UTF-8?q?docs(schemes):=20the=20zoo=20=E2=80=94=20?= =?UTF-8?q?symmetry=20table,=20fusion-as-cross,=20zygo-as-Decor,=20effectf?= =?UTF-8?q?ul=20drivers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D7 narrative: para/apo/histo/futu with runnable mdoc examples (the apo graft, course-of-value grandchildren, two-layers-per-step futu), fusion shown as the cross seam with the widening caveat, a zygomorphism written by hand as a user Decor value (the proof the public family replaces free-range gcata/gana), the M-generic drivers with the counted effectful-fetch example, and the BiAffine carrier paragraph with claims scoped to the shipped seams (matrix row + elgot = follow-ups). Plan: docs/plans/2026-06-11-001 stage 7 (docs half). Co-Authored-By: Claude Fable 5 --- site/docs/schemes.md | 173 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/site/docs/schemes.md b/site/docs/schemes.md index 14db416a..f18c4c5e 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -373,3 +373,176 @@ The single peel/glue layer is also available on its own as `Schemes.fLayer[F, S] `plate` for one layer. Given a `Foldable[F]` it reads a node's immediate foci via `.foldMap`. It is primarily the proof that a typed `F` is an optic carrier; 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**, and eo ships it as a vocabulary of +*decoration optics* (the `Decor` family, worn on the new `BiAffine` carrier — see below): + +| scheme | decoration | shape | `Decor` value | +|---|---|---|---| +| cata / ana | none | — | `Decor.cata` / `Decor.ana` | +| **para** | child slots carry the original subterms | product | `Decor.para` | +| **apo** | child slots may graft a finished subtree | sum | `Decor.apo` | +| **histo** | full decorated history per child (`Attr`) | iterated product | `Decor.histo` | +| **futu** | multiple layers per step (`Coattr`) | iterated sum | `Decor.futu` | +| zygo / dyna / … | user-written `Decor` values | — | (yours — example below) | + +`paraF` 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.paraF[BinF, Bin, Int] { (_, layer) => + layer match + case BinF.LeafF(_) => 0 + case BinF.BranchF((ls, l), (_, r)) => + l + r + (ls match { case Bin.Leaf(_) => 1; case _ => 0 }) +} +``` + +```scala mdoc +leftLeafBranches.get(binTree) +``` + +`apoF` 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 +val cached: Bin = binTree // an expensive subtree you already have + +val patched = Schemes.apoF[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 +patched.reverseGet(2) +``` + +`histoF` 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 +import dev.constructive.eo.schemes.{Attr, Coattr} + +// add each branch's grandchildren-through-history to its result +val withGrand = Schemes.histoF[BinF, Bin, Int] { (_, layer) => + layer match + 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) +} +``` + +`futuF` lets the coalgebra emit **several layers per step** (`Coattr.Roll` layers are unrolled +with no further coalgebra calls): + +```scala mdoc:silent +val twoAtATime = Schemes.futuF[BinF, Int, Bin] { n => + if n <= 1 then BinF.LeafF(1) + else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) +} +``` + +### Fusion is composition: `cross` + +`anaF`/`cataF` return concrete citizens (`AnaF`/`CataF`) carrying their (co)algebras, so the +build-output→read-input composition — the seam core's `Optic.cross` names — **fuses**: one +single-pass machine, each node built once and folded immediately, no full-tree retention and no +second traversal. (`hyloF` remains the zero-`S` spelling for seed-typed algebras; binding an +`AnaF` to a wider type falls back to the generic, materializing `cross` — extensionally equal.) + +```scala mdoc:silent +val zooExpand: Int => BinF[Int] = n => + if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) +val zooSum: (Bin, BinF[Int]) => Int = (_, fa) => + fa match { case BinF.LeafF(n) => n; case BinF.BranchF(l, r) => l + r } + +val fusedLeafSum = Schemes.anaF[BinF, Int, Bin](zooExpand).cross(Schemes.cataF(zooSum)) +``` + +```scala mdoc +fusedLeafSum.get(6) +``` + +### Write your own decoration: zygo as a `Decor` value + +The generality that droste exposes as `gcata`/`gana` lives here as the **public `Decor` family**: +a decoration is an optic over the `BiAffine` carrier (fold side: `from` = *gather*; unfold side: +`to` = *scatter*, `from` on `Step` = the seed-injecting unit). A zygomorphism — the algebra +consults a helper fold alongside each child's result — is a user-written gather value, consumed +by the same `cataF(decor)(galg)` driver as the named members: + +```scala mdoc:silent +import dev.constructive.eo.schemes.DecorGather +import dev.constructive.eo.data.BiAffine +import dev.constructive.eo.optics.Optic + +def zygo[B](helper: BinF[B] => B): DecorGather[BinF, (B, Int), Int] = + new Optic[Unit, (B, Int), Unit, Int, BiAffine]: + type X = (Unit, BinF[(B, Int)]) + def to(u: Unit): BiAffine[X, Unit] = + throw new UnsupportedOperationException("gather-only") + def from(xb: BiAffine[X, Int]): (B, Int) = xb match + case s: BiAffine.Step[X, Int] => + (helper(summon[Traverse[BinF]].map(s.snd)(_._1)), s.b) + case _: BiAffine.Done[X, Int] => + throw new UnsupportedOperationException("fold-side") + +val leafCount: BinF[Int] => Int = + { case BinF.LeafF(_) => 1; case BinF.BranchF(l, r) => l + r } + +// sum, with the helper count available at every branch +val sumWithCount = Schemes.cataF[BinF, Bin, (Int, Int), Int](zygo(leafCount)) { (_, layer) => + layer match + case BinF.LeafF(n) => n + case BinF.BranchF((_, l), (cr, r)) => l + r + 0 * cr // helper in scope per child +} +``` + +User-written values run the generic decoration route (one decoration dispatch + `Step` per +node); the named values dispatch to native engine routes. + +### Effectful steps: `cataFM` / `anaFM` / `hyloFM` + +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). Results are `Forget[M]`-carried citizens consumed via `.run`, and +`AnaFM.andThen(CataFM)` fuses (there `andThen` genuinely is the focus seam): + +```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))) + +val countedLeafSum = + Schemes + .anaFM[Counted, BinF, Int, Bin](fetchLayer) + .andThen(Schemes.cataFM[Counted, BinF, Bin, Int]((s, fa) => State.pure(zooSum(s, fa)))) +``` + +```scala mdoc +countedLeafSum.run(6).run(0).value // (service calls, leaf sum) — one fused pass +``` + +### The BiAffine carrier + +`Decor`'s carrier is new in core: **`BiAffine`** — `Affine`'s data shape worn on the *build* +seam. `Step(context, focus)` keeps going; `Done(payload)` means "this slot is already finished — +do not call the coalgebra" (apo grafts a finished subtree, futu unrolls a prebuilt layer). Its +laws are the graft-finality and round-trip equations in `cats-eo-laws`. Composition here is +scoped to the shipped seams — the fused `cross`, the M-path `andThen`, and the generic drivers; +BiAffine's full composition-matrix row is follow-up work, as are the elgot/coelgot decorations +(the answer-level short-circuit, which the M machine's internals are already shaped for). From 21c985d3743e1852fd8374d60964743dd145f5b1 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 01:42:47 +0200 Subject: [PATCH 16/61] docs(plans): mark BiAffine zoo plan implemented (bench numbers + review pending) Co-Authored-By: Claude Fable 5 --- docs/plans/2026-06-11-001-feat-biaffine-scheme-zoo-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index bdb0073f..7443cad4 100644 --- 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 @@ -1,7 +1,7 @@ --- title: "feat: BiAffine carrier + the typed recursion-scheme zoo as optics (para/apo/histo/futu, M-generic driver)" type: feat -status: draft +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) From e177f687c877d60d251fd0e09f650105d1ec3f0b Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 08:11:29 +0200 Subject: [PATCH 17/61] =?UTF-8?q?perf(schemes):=20CI-driven=20allocation?= =?UTF-8?q?=20wins=20=E2=80=94=20M-machine=20=E2=88=9249%=20B/op,=20fused?= =?UTF-8?q?=20cross=20below=20materializing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the first CI gc sweep (run 27384569800, 8k-node fixture): - foldLayeredM 1,606,586 → 820,258 B/op (−49%, local confirm): leaf combine inlined into the Down event (no frame push + extra event per leaf), last-child store merged with the combine event, and the loop state re-encoded as a bare AnyRef with an AscendToken sentinel — one Either per descend instead of the nested Left(Right(n)), ascend a hoisted constant. - fusedPairedFold 1,049,417 → 820,065 B/op: the F[(S, A)] intermediate (a third F-alloc per node that put the fused cross ABOVE the materializing composition, 1049k vs 886k) replaced by building F[S] and F[A] straight from the out-array. Fused now wins both ns and B/op. - histoF/futuF switched to native routes (combine builds Attr directly; expand matches Coattr directly), law-pinned equal to the generic Decor routes in DecorLawsSpec. B/op unchanged — the generic routes' Step allocations were already EA-elided (same finding as the cata fast path); scaladocs now state the honest story: the remaining gap to droste's histo/futu is the stack-safe machine's per-node child array, vs droste zoo's stack-UNSAFE plain recursion. CI scoreboard (before this commit) recorded for the docs: para HALVES droste's allocation (558k vs 1,115k) at 1.55x the speed; apo 655k vs 1,147k; graft parity (27 vs 35 ns, both O(1)); cataF == hyloF == 361,385 B/op (the Decor.id re-derivation pin passed — fast path is the direct machine, and the generic route is EA-elided to the same number). Plan: docs/plans/2026-06-11-001 stage 6 (perf pass). Co-Authored-By: Claude Fable 5 --- .../dev/constructive/eo/schemes/Schemes.scala | 119 +++++++++++++----- .../eo/schemes/DecorLawsSpec.scala | 26 ++++ 2 files changed, 111 insertions(+), 34 deletions(-) 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 6344b27c..6813f178 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -61,6 +61,11 @@ object Schemes: */ private val EmptyAnyRefs: Array[AnyRef] = new Array[AnyRef](0) + /** Sentinel op for [[foldLayeredM]]'s loop state — "ascend": consume `ret` against the top frame. + * Anything else on the loop is the node to descend into. + */ + private object AscendToken + /** Engine for the *fold* schemes ([[cata]] / [[hylo]]). `expand` yields a node's children; * `combine` folds a node plus its already-folded children — re-supplied the node, so it needs no * per-node closure. Stack-safe for any *terminating* `expand` (past the on-stack limit the @@ -500,39 +505,44 @@ object Schemes: n0 => val stack = new java.util.ArrayDeque[Frame]() var ret: AnyRef = null.asInstanceOf[AnyRef] - // Op: Right(n) = descend into n; Left(()) = ascend (consume ret against the top frame). - M.tailRecM[Either[Unit, N], R](Right(n0)) { - case Right(n) => - M.map(expandOr(n)) { + // Op encoding (allocation-lean — CI 2026-06-12: per-event Either allocation + // dominated the M path's 1.6M B/op): the loop state is a bare AnyRef — the + // AscendToken sentinel means "consume ret against the top frame", anything else + // is the node to descend into. One Left per descend (vs nested Left(Right(n))); + // the ascend step is the hoisted constant. + val ascend: Either[AnyRef, R] = Left(AscendToken) + M.tailRecM[AnyRef, R](n0.asInstanceOf[AnyRef]) { op => + if op.asInstanceOf[AnyRef] ne AscendToken then + val n = op.asInstanceOf[N] + M.flatMap(expandOr(n)) { case Left(r) => // graft/short-circuit arm (unused by v1 drivers) ret = r.asInstanceOf[AnyRef] - Left(Left(())) + M.pure(ascend) case Right(layer) => val arr = childrenArr(layer) if arr.length == 0 then - // leaf: combine is the next event — model as a frame with no children left - stack.push(new Frame(n, layer, arr, 0)) - Left(Left(())) + // leaf: combine INLINE (a constant second bind — no frame, no extra event) + M.map(combine(n, layer, arr)) { r => + ret = r.asInstanceOf[AnyRef] + ascend + } else stack.push(new Frame(n, layer, arr, 0)) - Left(Right(arr(0).asInstanceOf[N])) + M.pure(Left(arr(0))) } - case Left(()) => - if stack.isEmpty then M.pure(Right(ret.asInstanceOf[R])) + else if stack.isEmpty then M.pure(Right(ret.asInstanceOf[R])) + else + val fr = stack.peek() + fr.arr(fr.i) = ret // store the just-folded child's result + fr.i += 1 + if fr.i < fr.arr.length then M.pure(Left(fr.arr(fr.i))) else - val fr = stack.peek() - if fr.arr.length == 0 || fr.i >= fr.arr.length then - // all children folded (or leaf): one combine event, then keep ascending - M.map(combine(fr.node, fr.layer, fr.arr)) { r => - val _ = stack.pop() - ret = r.asInstanceOf[AnyRef] - Left(Left(())) - } - else - fr.arr(fr.i) = ret // store the just-folded child's result - fr.i += 1 - if fr.i < fr.arr.length then M.pure(Left(Right(fr.arr(fr.i).asInstanceOf[N]))) - else M.pure(Left(Left(()))) // last child stored: next event is this frame's combine + // last child stored: combine NOW (merged — no intermediate pure event) + M.map(combine(fr.node, fr.layer, fr.arr)) { r => + val _ = stack.pop() + ret = r.asInstanceOf[AnyRef] + ascend + } } /** Effectful catamorphism — the algebra runs in `M` (`(S, F[A]) => M[A]`); the layer peel stays @@ -675,15 +685,26 @@ object Schemes: ) /** Histomorphism over a typed pattern functor `F` — the algebra sees each child's **full - * decorated history** ([[Attr]]: result + that child's own decorated layer). Definitional: the - * generic decorated fold at [[Decor.histo]], whose gather is the `Attr` constructor. + * decorated history** ([[Attr]]: result + that child's own decorated layer). + * + * Native route: the combine builds the `Attr` directly, the root projects its head — one less + * dispatch than the generic [[Decor.histo]] route (whose `Step` is EA-elided: B/op identical; + * law-pinned equal in `DecorLawsSpec`). The remaining gap to droste's histo (558k vs 361k B/op + * on the 8k-node fixture) is the stack-safe machine's per-node child array — droste's zoo + * recursion is stack-UNSAFE plain recursion; the ~24 B/node is the price of the guarantee. * * Space honesty: course-of-value recursion retains O(n) `Attr` cells by nature. */ def histoF[F[_], S, A]( alg: (S, F[Attr[F, A]]) => A - )(using Traverse[F], Project[F, S]): Getter[S, A] = - cataF[F, S, Attr[F, A], A](Decor.histo[F, A])(alg) + )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = + val toAttr: S => Attr[F, A] = foldLayered[F, S, Attr[F, A]]( + P.project, + (s, fs, out) => + val layer = rebuildLayer[F, S, Attr[F, A]](fs, out) + Attr(alg(s, layer), layer), + ) + Getter[S, A](s => toAttr(s).head) def anaF[F[_], Seed, S]( coalg: Seed => F[Seed] @@ -701,9 +722,27 @@ object Schemes: foldLayered[F, Seed, (S, A)]( coalg, (_, fSeed, out) => - val pairs = rebuildLayer[F, Seed, (S, A)](fSeed, out) - val s = E.embed(F.map(pairs)(_._1)) - (s, alg(s, F.map(pairs)(_._2))), + // Build F[S] and F[A] straight from the out-array — no F[(S, A)] intermediate + // (CI 2026-06-12: that third F-alloc per node put the fused cross ABOVE the + // materializing composition in B/op, 1049k vs 886k). + val fS = + if out.length == 0 then fSeed.asInstanceOf[F[S]] + else + var i = -1 + F.map(fSeed) { _ => + i += 1 + out(i).asInstanceOf[(S, A)]._1 + } + val fA = + if out.length == 0 then fSeed.asInstanceOf[F[A]] + else + var i = -1 + F.map(fSeed) { _ => + i += 1 + out(i).asInstanceOf[(S, A)]._2 + } + val s = E.embed(fS) + (s, alg(s, fA)), ) /** Apomorphism over a typed pattern functor `F` — per child slot the coalgebra answers @@ -726,12 +765,24 @@ object Schemes: /** Futumorphism over a typed pattern functor `F` — the coalgebra may emit **multiple layers per * step** ([[Coattr]]: `Pure` keeps unfolding, `Roll` is a prebuilt layer unrolled with no - * coalgebra call). Definitional: the generic decorated unfold at [[Decor.futu]]. + * coalgebra call). + * + * Native route: the expand matches `Coattr` directly — one less dispatch than the generic + * [[Decor.futu]] route (whose per-slot `Step` is EA-elided: B/op identical; law-pinned equal in + * `DecorLawsSpec`). The gap to droste's futu (655k vs 459k B/op) is the stack-safe machine's + * per-node child array — droste's zoo recursion is stack-unsafe. */ def futuF[F[_], A, S]( coalg: A => F[Coattr[F, A]] - )(using Traverse[F], Embed[F, S]): Review[S, A] = - anaF[F, A, Coattr[F, A], S](Decor.futu[F, A])(coalg) + )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = + val expand: Coattr[F, A] => F[Coattr[F, A]] = + case Coattr.Pure(a) => coalg(a) + case Coattr.Roll(layer) => layer + val build = foldLayered[F, Coattr[F, A], S]( + expand, + (_, fw, out) => E.embed(rebuildLayer[F, Coattr[F, A], S](fw, out)), + ) + Review[S, A](a => build(Coattr.Pure(a))) /** Generalized (decorated) anamorphism — the gana of the typed path, with the decoration supplied * as a [[DecorScatter]] optic value. Each `W` slot is scattered (the decoration's `to`): diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala index 206d747c..99b02523 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala @@ -157,3 +157,29 @@ class DecorLawsSpec extends Specification: .reverseGet(3) built === Bin.Branch(Bin.Leaf(3), Bin.Branch(Bin.Leaf(2), Bin.Leaf(1))) } + + // ----- native routes == generic decoration routes (the perf-win pins) -------- + + "native histoF == the generic route at Decor.histo" >> { + val alg: (Bin, BinF[Attr[BinF, Int]]) => Int = (s, layer) => + sumAlg( + s, + BinF + .traverse + .map(layer)(a => + a.head + a.tail.match { + case BinF.LeafF(_) => 0; case BinF.BranchF(l, r) => l.head + r.head + } + ), + ) + Schemes.histoF[BinF, Bin, Int](alg).get(tree) === + Schemes.cataF[BinF, Bin, Attr[BinF, Int], Int](Decor.histo[BinF, Int])(alg).get(tree) + } + + "native futuF == the generic route at Decor.futu" >> { + def coalg(n: Int): BinF[Coattr[BinF, Int]] = + if n <= 1 then BinF.LeafF(1) + else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) + Schemes.futuF[BinF, Int, Bin](coalg).reverseGet(4) === + Schemes.anaF[BinF, Int, Coattr[BinF, Int], Bin](Decor.futu[BinF, Int])(coalg).reverseGet(4) + } From c37a8f9448ff66d080336c3ca1fca137b35b2de0 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 09:14:13 +0200 Subject: [PATCH 18/61] =?UTF-8?q?fix(schemes):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20fresh-state-per-force=20M-machine,=20single-pass=20?= =?UTF-8?q?childrenArr,=20test=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the 8-persona branch review (no P0; 1 P1, fixed structurally): - foldLayeredM allocates its mutable state INSIDE the M (M.flatMap(M.unit)): re-forcing one lazy M[R] (Eval) now gets fresh frames — the exception-then-reforce wrong-result hazard is gone, and double-force is law-pinned. Concurrent forcing of a single value remains unsupported (documented). - childrenArr: three byte-identical nested copies extracted to one object-level single-pass helper (ObjArrBuilder — no more F.size + foldLeft double traversal per node); fusedPairedFoldM mirrors the pure path (no F[(S,A)] intermediate). Local smoke: eoHyloM 820k → 624k B/op (−61% from the original 1.6M); eoCataF byte-stable 361k. - BiAffine hashCode null-guards; FoldFM sealed; the orphaned anaF scaladoc re-homed (it was documenting paraF); tautological para-coherence test rewritten to actually compare paired subterm re-folds; contract scaladocs (sequential-M, Done.fst runtime contract, type-param order cross-refs, leaf phantom-recast). - New pins: paraF/apoF == their generic Decor routes end-to-end (the last unexercised driver branches), heap-path graft eq (depth 600), cataFM/anaFM[Option] mid-fold short-circuit, Eval re-force + exception-interrupted re-force, widenB identity. 525 tests green (90 schemes). Co-Authored-By: Claude Fable 5 --- .../dev/constructive/eo/data/BiAffine.scala | 6 +- .../constructive/eo/schemes/CitizensM.scala | 6 +- .../dev/constructive/eo/schemes/Schemes.scala | 189 ++++++++++-------- .../eo/schemes/DecorLawsSpec.scala | 34 ++++ .../eo/schemes/SchemesFMSpec.scala | 51 +++++ .../eo/schemes/SchemesZooSpec.scala | 44 +++- .../dev/constructive/eo/BiAffineSpec.scala | 5 + 7 files changed, 240 insertions(+), 95 deletions(-) diff --git a/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala index 71ae4e9c..40831290 100644 --- a/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala +++ b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala @@ -53,7 +53,7 @@ object BiAffine: case other: Done[?, ?] => fst == other.fst case _ => false - override def hashCode(): Int = fst.hashCode + override def hashCode(): Int = if fst.asInstanceOf[AnyRef] == null then 0 else fst.hashCode /** Re-type this `Done[A, B]` as `Done[A, B2]` without allocating a new instance. Safe because * `Done` stores only `fst: Fst[A]` — the `B` parameter is phantom at the runtime shape. @@ -68,7 +68,9 @@ object BiAffine: case other: Step[?, ?] => snd == other.snd && b == other.b case _ => false - override def hashCode(): Int = snd.hashCode * 31 + (if b == null then 0 else b.hashCode) + override def hashCode(): Int = + (if snd.asInstanceOf[AnyRef] == null then 0 else snd.hashCode) * 31 + + (if b.asInstanceOf[AnyRef] == null then 0 else b.hashCode) /** Finished-arm constructor. * diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala index 24530bf5..7e8b7ce4 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala @@ -21,6 +21,10 @@ import optics.Optic * effects) would share that state across branches and corrupt the fold. See the linear-M boundary * test in `SchemesFMSpec`. A persistent-state variant is deferred until a real consumer needs one. * + * Re-forcing the same `M[A]` value returned by [[FoldFM.run]] is safe — each force allocates its + * own fresh mutable state (the frame deque is allocated inside the `M`, not before it). Concurrent + * forcing of a single `M[A]` value remains unsupported; each `run(s)` call is independent. + * * Widening hazard (the M-path mirror of `AnaF.cross`'s): a widened `AnaFM` still typechecks * through the generic trait `andThen` via `assocForgetMonad` — extensionally equal but * MATERIALIZING (`M[S]` built, then folded). `Schemes.hyloFM` stays the always-fused M spelling; @@ -30,7 +34,7 @@ import optics.Optic /** Generic effectful-fold citizen: `run: S => M[A]`. What `hyloFM` and the fused * `AnaFM.andThen(CataFM)` return. */ -class FoldFM[M[_], S, A] private[schemes] (val run: S => M[A]) +sealed class FoldFM[M[_], S, A] private[schemes] (val run: S => M[A]) extends Optic[S, Unit, A, Unit, Forget[M]]: type X = Nothing 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 6813f178..de08c035 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -61,6 +61,25 @@ object Schemes: */ private val EmptyAnyRefs: Array[AnyRef] = new Array[AnyRef](0) + /** Collect the children of typed layer `fn` into a flat `Array[AnyRef]`, single-pass via + * `ObjArrBuilder`. Returns [[EmptyAnyRefs]] for leaf layers (zero children) to avoid a per-leaf + * empty-array allocation. Used by [[foldLayered]], [[foldLayeredOr]], and [[foldLayeredM]] — one + * definition replaces the three identical nested `def childrenArr` that previously lived inside + * each engine. + * + * Leaf layers are a common case in typed pattern functors (every `LeafF`-like constructor + * carries no recursive slots), so the shared `EmptyAnyRefs` guard pays for itself. + */ + private def childrenArr[F[_], N](fn: F[N])(using F: Traverse[F]): Array[AnyRef] = + val n = F.size(fn).toInt + if n == 0 then EmptyAnyRefs + else + val b = new data.ObjArrBuilder(n) + val _ = F.foldLeft(fn, ()) { (_, child) => + b.unsafeAppend(child.asInstanceOf[AnyRef]) + } + b.freezeArr + /** Sentinel op for [[foldLayeredM]]'s loop state — "ascend": consume `ret` against the top frame. * Anything else on the loop is the node to descend into. */ @@ -353,17 +372,6 @@ object Schemes: combine: (N, F[N], Array[AnyRef]) => R, )(using F: Traverse[F]): N => R = - def childrenArr(fn: F[N]): Array[AnyRef] = - val n = F.size(fn).toInt - if n == 0 then EmptyAnyRefs - else - val arr = new Array[AnyRef](n) - val _ = F.foldLeft(fn, 0) { (i, child) => - arr(i) = child.asInstanceOf[AnyRef] - i + 1 - } - arr - def heap(root: N): R = final class Frame(val node: N, val layer: F[N], val arr: Array[AnyRef], var i: Int) val stack = new java.util.ArrayDeque[Frame]() @@ -412,17 +420,6 @@ object Schemes: combine: (F[N], Array[AnyRef]) => R, )(using F: Traverse[F]): N => R = - def childrenArr(fn: F[N]): Array[AnyRef] = - val n = F.size(fn).toInt - if n == 0 then EmptyAnyRefs - else - val arr = new Array[AnyRef](n) - val _ = F.foldLeft(fn, 0) { (i, child) => - arr(i) = child.asInstanceOf[AnyRef] - i + 1 - } - arr - def heap(root: N): R = final class Frame(val layer: F[N], val arr: Array[AnyRef], var i: Int) val stack = new java.util.ArrayDeque[Frame]() @@ -475,6 +472,9 @@ object Schemes: // Supported Ms are SINGLE-PASS and LINEAR: the machine'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 SchemesFMSpec. + // 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 @@ -482,67 +482,60 @@ object Schemes: // =========================================================================================== /** The lifted machine. One `M`-action per `tailRecM` iteration: `Down(n)` runs `expandOr`, exits - * run `combine`; the mutable frame deque lives outside the loop (linear-M contract). + * run `combine`; the mutable frame deque is allocated per-force (inside the `M`) so re-forcing + * the same `M[R]` value allocates fresh state. Concurrent forcing of a single `M[R]` value + * remains unsupported (mutable state, linear-M contract); each `run(s)` call is independent. */ private def foldLayeredM[M[_], F[_], N, R]( expandOr: N => M[Either[R, F[N]]], combine: (N, F[N], Array[AnyRef]) => M[R], )(using M: Monad[M], F: Traverse[F]): N => M[R] = - def childrenArr(fn: F[N]): Array[AnyRef] = - val n = F.size(fn).toInt - if n == 0 then EmptyAnyRefs - else - val arr = new Array[AnyRef](n) - val _ = F.foldLeft(fn, 0) { (i, child) => - arr(i) = child.asInstanceOf[AnyRef] - i + 1 - } - arr - final class Frame(val node: N, val layer: F[N], val arr: Array[AnyRef], var i: Int) n0 => - val stack = new java.util.ArrayDeque[Frame]() - var ret: AnyRef = null.asInstanceOf[AnyRef] - // Op encoding (allocation-lean — CI 2026-06-12: per-event Either allocation - // dominated the M path's 1.6M B/op): the loop state is a bare AnyRef — the - // AscendToken sentinel means "consume ret against the top frame", anything else - // is the node to descend into. One Left per descend (vs nested Left(Right(n))); - // the ascend step is the hoisted constant. - val ascend: Either[AnyRef, R] = Left(AscendToken) - M.tailRecM[AnyRef, R](n0.asInstanceOf[AnyRef]) { op => - if op.asInstanceOf[AnyRef] ne AscendToken then - val n = op.asInstanceOf[N] - M.flatMap(expandOr(n)) { - case Left(r) => // graft/short-circuit arm (unused by v1 drivers) - ret = r.asInstanceOf[AnyRef] - M.pure(ascend) - case Right(layer) => - val arr = childrenArr(layer) - if arr.length == 0 then - // leaf: combine INLINE (a constant second bind — no frame, no extra event) - M.map(combine(n, layer, arr)) { r => - ret = r.asInstanceOf[AnyRef] - ascend - } - else - stack.push(new Frame(n, layer, arr, 0)) - M.pure(Left(arr(0))) - } - else if stack.isEmpty then M.pure(Right(ret.asInstanceOf[R])) - else - val fr = stack.peek() - fr.arr(fr.i) = ret // store the just-folded child's result - fr.i += 1 - if fr.i < fr.arr.length then M.pure(Left(fr.arr(fr.i))) - else - // last child stored: combine NOW (merged — no intermediate pure event) - M.map(combine(fr.node, fr.layer, fr.arr)) { r => - val _ = stack.pop() - ret = r.asInstanceOf[AnyRef] - ascend + M.flatMap(M.unit) { _ => + val stack = new java.util.ArrayDeque[Frame]() + var ret: AnyRef = null.asInstanceOf[AnyRef] + // Op encoding (allocation-lean — CI 2026-06-12: per-event Either allocation + // dominated the M path's 1.6M B/op): the loop state is a bare AnyRef — the + // AscendToken sentinel means "consume ret against the top frame", anything else + // is the node to descend into. One Left per descend (vs nested Left(Right(n))); + // the ascend step is the hoisted constant. + val ascend: Either[AnyRef, R] = Left(AscendToken) + M.tailRecM[AnyRef, R](n0.asInstanceOf[AnyRef]) { op => + if op.asInstanceOf[AnyRef] ne AscendToken then + val n = op.asInstanceOf[N] + M.flatMap(expandOr(n)) { + case Left(r) => // graft/short-circuit arm (unused by v1 drivers) + ret = r.asInstanceOf[AnyRef] + M.pure(ascend) + case Right(layer) => + val arr = childrenArr(layer)(using F) + if arr.length == 0 then + // leaf: combine INLINE (a constant second bind — no frame, no extra event) + M.map(combine(n, layer, arr)) { r => + ret = r.asInstanceOf[AnyRef] + ascend + } + else + stack.push(new Frame(n, layer, arr, 0)) + M.pure(Left(arr(0))) } + else if stack.isEmpty then M.pure(Right(ret.asInstanceOf[R])) + else + val fr = stack.peek() + fr.arr(fr.i) = ret // store the just-folded child's result + fr.i += 1 + if fr.i < fr.arr.length then M.pure(Left(fr.arr(fr.i))) + else + // last child stored: combine NOW (merged — no intermediate pure event) + M.map(combine(fr.node, fr.layer, fr.arr)) { r => + val _ = stack.pop() + ret = r.asInstanceOf[AnyRef] + ascend + } + } } /** Effectful catamorphism — the algebra runs in `M` (`(S, F[A]) => M[A]`); the layer peel stays @@ -590,7 +583,10 @@ object Schemes: /** Single-pass paired machine in `M` backing the fused `AnaFM.andThen(CataFM)` — the M mirror of * [[fusedPairedFold]]: each node built once, folded immediately, no `M[S]` whole-structure - * materialization. + * materialization. Mirrors the pure version exactly: `F[S]` and `F[A]` are built straight from + * the out-array with two `F.map(fSeed)` passes and `var i = -1` counters, avoiding the + * `F[(S,A)]` intermediate. Leaf layers are phantom-recast (valid because pattern-functor leaves + * have no recursive slots by definition). */ private[schemes] def fusedPairedFoldM[M[_], F[_], Seed, S, A]( coalgM: Seed => M[F[Seed]], @@ -599,9 +595,25 @@ object Schemes: foldLayeredM[M, F, Seed, (S, A)]( seed => M.map(coalgM(seed))(Right(_)), (_, fSeed, out) => - val pairs = rebuildLayer[F, Seed, (S, A)](fSeed, out) - val s = E.embed(F.map(pairs)(_._1)) - M.map(algM(s, F.map(pairs)(_._2)))(a => (s, a)), + // Build F[S] and F[A] straight from the out-array — no F[(S,A)] intermediate. + val fS = + if out.length == 0 then fSeed.asInstanceOf[F[S]] + else + var i = -1 + F.map(fSeed) { _ => + i += 1 + out(i).asInstanceOf[(S, A)]._1 + } + val fA = + if out.length == 0 then fSeed.asInstanceOf[F[A]] + else + var i = -1 + F.map(fSeed) { _ => + i += 1 + out(i).asInstanceOf[(S, A)]._2 + } + val s = E.embed(fS) + M.map(algM(s, fA))(a => (s, a)), ) /** The single *layer* optic for a pattern functor `F`: `project`/`embed` worn as the existing @@ -640,6 +652,9 @@ object Schemes: * [[Decor.cata]] (recognised by identity — the direct, decoration-free engine path), `histoF` * with [[Decor.histo]]; user-written decorations (zygo, dyna, …) run the generic route, which * pays one decoration dispatch + `Step` per node. + * + * (type-param order: `[F, S, W, A]` — compare [[anaF]] `[F, A, W, S]`, which mirrors these in + * input-before-output order: `A` is the input seed there, `S` the built output.) */ def cataF[F[_], S, W, A]( decor: DecorGather[F, W, A] @@ -662,12 +677,6 @@ object Schemes: galg(s, F.map(layer)(toW)) } - /** Anamorphism over a typed pattern functor `F`, as a `Review`. The single fused coalgebra `Seed - * => F[Seed]` yields one typed layer of child seeds; [[Embed]] assembles each layer into the - * built `S`. Materializing — the built `S` is O(nodes). Stack-safe (the [[foldLayered]] machine). - * Requires `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input before output) - * to match [[hyloF]] and the `PSVec` [[ana]]. - */ /** Paramorphism over a typed pattern functor `F` — each child slot pairs the **original subterm** * with its folded result. Native route: the machine already walks real `S` nodes and keeps each * frame's projected layer, so subterms are paired positionally — no per-node re-`embed` @@ -706,6 +715,12 @@ object Schemes: ) Getter[S, A](s => toAttr(s).head) + /** Anamorphism over a typed pattern functor `F`, as a `Review`. The single fused coalgebra `Seed + * => F[Seed]` yields one typed layer of child seeds; [[Embed]] assembles each layer into the + * built `S`. Materializing — the built `S` is O(nodes). Stack-safe (the [[foldLayered]] machine). + * Requires `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input before output) + * to match [[hyloF]] and the `PSVec` [[ana]]. + */ def anaF[F[_], Seed, S]( coalg: Seed => F[Seed] )(using F: Traverse[F], E: Embed[F, S]): AnaF[F, Seed, S] = @@ -713,7 +728,8 @@ object Schemes: /** Single-pass paired machine backing the fused `AnaF.cross(CataF)`: each node is built once (the * algebra is node-supplied — construction is semantically required), folded immediately, and - * released as the fold ascends. No full-tree retention, no second traversal. + * released as the fold ascends. No full-tree retention, no second traversal. Leaf layers are + * phantom-recast (valid because pattern-functor leaves have no recursive slots by definition). */ private[schemes] def fusedPairedFold[F[_], Seed, S, A]( coalg: Seed => F[Seed], @@ -791,6 +807,11 @@ object Schemes: * gana's `pure`). `anaF(coalg)` routes here with [[Decor.ana]] (identity-recognised direct * path); `futuF` with [[Decor.futu]]; `Decor.apo` runs the generic distApo route — the O(1) * graft belongs to the native `apoF` engine. + * + * For user-written [[DecorScatter]] values, `Done.fst` MUST carry `F[W]` at runtime — the engine + * unrolls it directly as the next layer. + * + * (type-param order: compare [[cataF]] `[F, S, W, A]` — the fold mirror swaps `Seed`/`A`.) */ def anaF[F[_], A, W, S]( decor: DecorScatter[F, W, A] diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala index 99b02523..acd29eaf 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala @@ -183,3 +183,37 @@ class DecorLawsSpec extends Specification: Schemes.futuF[BinF, Int, Bin](coalg).reverseGet(4) === Schemes.anaF[BinF, Int, Coattr[BinF, Int], Bin](Decor.futu[BinF, Int])(coalg).reverseGet(4) } + + // ----- generic-route end-to-end pins -------------------------------------- + + "native paraF == the generic route at Decor.para" >> { + // Algebra that uses BOTH the paired subterm and the result: + // for a branch, sum child results and add 1 for each child subterm that is a Leaf. + // tree = Branch(Branch(Leaf(1), Leaf(2)), Leaf(3)) + // Leaf(1) → 1; Leaf(2) → 2 + // Branch(Leaf(1), Leaf(2)) → 1+2 + 1 (Leaf(1)) + 1 (Leaf(2)) = 5 + // Leaf(3) → 3 + // Branch(Branch(L1,L2), Leaf(3)) → 5+3 + 0 (left is Branch) + 1 (Leaf(3)) = 9 + val alg: (Bin, BinF[(Bin, Int)]) => Int = (_, layer) => + layer match + case BinF.LeafF(n) => n + case BinF.BranchF((ls, la), (rs, ra)) => + la + ra + (if ls.isInstanceOf[Bin.Leaf] then 1 else 0) + + (if rs.isInstanceOf[Bin.Leaf] then 1 else 0) + Schemes.paraF[BinF, Bin, Int](alg).get(tree) === + Schemes.cataF[BinF, Bin, (Bin, Int), Int](Decor.para[BinF, Bin, Int])(alg).get(tree) + } + + "native apoF == the generic route at Decor.apo (distApo)" >> { + // Coalg with one graft: at n <= 0 emit a leaf; otherwise graft Bin.Leaf(n) as left child. + // The native apoF places the graft by reference; the generic route (distApo via project) + // rebuilds it — structural equality (==) holds, reference identity (eq) only for native. + def coalg(n: Int): BinF[Either[Bin, Int]] = + if n <= 0 then BinF.LeafF(0) + else BinF.BranchF(Left(Bin.Leaf(n)), Right(n - 1)) + val nativeResult = Schemes.apoF[BinF, Int, Bin](coalg).reverseGet(2) + val genericResult = + Schemes.anaF[BinF, Int, Either[Bin, Int], Bin](Decor.apo[BinF, Bin, Int])(coalg).reverseGet(2) + // Both produce the same tree by value; use == not eq (generic route REBUILDS the graft). + nativeResult === genericResult + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala index e787463d..e1496e76 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala @@ -106,6 +106,57 @@ class SchemesFMSpec extends Specification: (results != List(1, 2)) must beTrue } + // ----- propagation of short-circuiting effects -------------------------------- + + "cataFM[Option] propagates a mid-fold None" >> { + // Algebra returns None for the inner branch (the Branch(Leaf(1), Leaf(2)) node) only. + val algM: (Bin, BinF[Int]) => Option[Int] = (s, fa) => + s match + case Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)) => None // force failure at this node + case _ => Some(sumAlg(s, fa)) + Schemes.cataFM[Option, BinF, Bin, Int](algM).run(tree) === None + } + + "anaFM[Option] propagates a mid-unfold None" >> { + // Coalg returns None at seed 3 — forces failure mid-build. + val coalgM: Int => Option[BinF[Int]] = n => if n == 3 then None else Some(expand(n)) + Schemes.anaFM[Option, BinF, Int, Bin](coalgM).run(6) === None + } + + // ----- re-forcing the same Eval result is safe (Fix 1 regression guard) ------ + + "re-forcing the same Eval result is safe (fresh state per force)" >> { + val m = Schemes + .hyloFM[Eval, BinF, Int, Int]( + n => Eval.now(expand(n)), + (s, fa) => Eval.now(sumAlgSeed(s, fa)), + ) + .run(6) + val expected = Schemes.hyloF[BinF, Int, Int](expand, sumAlgSeed).get(6) + (m.value === expected).and(m.value === expected) + } + + "exception-interrupted force then re-force computes correctly (fresh state per force)" >> { + // On the first invocation of the 3-node specific interior seed, throw via a one-shot flag. + var thrown = false + val algM: (Int, BinF[Int]) => Eval[Int] = (seed, fa) => + if seed == 3 && !thrown then + thrown = true + Eval.later(throw new RuntimeException("deliberate first-force failure")) + else Eval.now(sumAlgSeed(seed, fa)) + val m = Schemes + .hyloFM[Eval, BinF, Int, Int](n => Eval.now(expand(n)), algM) + .run(6) + // First force: throws + val firstThrew = + try { val _ = m.value; false } + catch + case _: RuntimeException => true + // Second force: flag already set, should succeed with correct answer + val expected = Schemes.hyloF[BinF, Int, Int](expand, sumAlgSeed).get(6) + firstThrew.and(m.value === expected) + } + // ----- the arbo-shaped acceptance example ------------------------------------- "arbo-shaped: effectful children (counted fetches), built and folded in one fused pass" >> { diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala index 4d90e1d5..ef50ff10 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala @@ -37,18 +37,23 @@ class SchemesZooSpec extends Specification: } "paraF sees the original subterm at every child slot" >> { - // The algebra checks each paired subterm re-folds to the paired result. + // Algebra returns (sum, ok): sum is the cataF sum of the subtree; ok checks the PAIRED + // subterm re-folds to the PAIRED result — a non-tautological cross-check. val coherent = Schemes - .paraF[BinF, Bin, Boolean] { (_, layer) => + .paraF[BinF, Bin, (Int, Boolean)] { (_, layer) => layer match - case BinF.LeafF(_) => true - case BinF.BranchF((ls, lOk), (rs, rOk)) => - lOk && rOk && - Schemes.cataF(sumAlg).get(ls) == Schemes.cataF(sumAlg).get(ls) && - Schemes.cataF(sumAlg).get(rs) == Schemes.cataF(sumAlg).get(rs) + case BinF.LeafF(n) => (n, true) + case BinF.BranchF((ls, (lSum, lOk)), (rs, (rSum, rOk))) => + ( + lSum + rSum, + lOk && rOk && + Schemes.cataF(sumAlg).get(ls) == lSum && + Schemes.cataF(sumAlg).get(rs) == rSum, + ) } .get(tree) - coherent === true + // tree = Branch(Branch(Leaf(1), Leaf(2)), Branch(Leaf(3), Leaf(4))); leaf sum = 1+2+3+4 = 10 + coherent === (10, true) } "never-grafting apoF == anaF" >> { @@ -94,6 +99,29 @@ class SchemesZooSpec extends Specification: (graftSlot.asInstanceOf[AnyRef] eq grafted.asInstanceOf[AnyRef]) === true } + "apoF grafts by reference on the HEAP path too (depth > OnStackLimit=512)" >> { + val grafted: Bin = Bin.Branch(Bin.Leaf(77), Bin.Leaf(88)) + // Descend a spine to depth 600 (past the on-stack limit of 512), then graft once. + val GraftDepth = 600 + def coalg(n: Int): BinF[Either[Bin, Int]] = + if n <= 0 then BinF.LeafF(0) + else if n == 1 then BinF.BranchF(Left(grafted), Right(0)) + else BinF.BranchF(Right(n - 1), Left(Bin.Leaf(0))) + val built = Schemes.apoF[BinF, Int, Bin](coalg).reverseGet(GraftDepth) + // Navigate left spine to find the graft slot (iterative — safe at any depth) + var cursor: Bin = built + var steps = GraftDepth - 1 + while steps > 0 do + cursor = cursor match + case Bin.Branch(l, _) => l + case leaf => leaf + steps -= 1 + val graftSlot = cursor match + case Bin.Branch(left, _) => left + case other => other + (graftSlot.asInstanceOf[AnyRef] eq grafted.asInstanceOf[AnyRef]) === true + } + "histoF uses real history: leaf-depth-weighted sum needs grandchildren" >> { // An algebra unreachable by plain cata in one pass: each branch adds its // grandchildren's results twice (course-of-value: reads two levels down). diff --git a/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala index 03bfa1f2..e7fcba98 100644 --- a/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala @@ -30,6 +30,11 @@ class BiAffineSpec extends Specification: case d: Done[X, Int] => d.fst case s: Step[X, Int] => s.b + "Done.widenB is allocation-free (reference-equal result)" in { + val d = new Done[TX, Int](5) + (d.widenB[String].asInstanceOf[AnyRef] eq d.asInstanceOf[AnyRef]) === true + } + "a full BiAffine citizen" should { "treat Done as final: from(Done(w)) == w" in { From bc6fe7cb7adb56b985975f359f1f2d1d9189234c Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 09:34:55 +0200 Subject: [PATCH 19/61] =?UTF-8?q?refactor(schemes)!:=20drop=20the=20untype?= =?UTF-8?q?d=20PSVec=20cata/ana/hylo=20=E2=80=94=20the=20typed=20path=20su?= =?UTF-8?q?bsumes=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User decision (supersedes plan decision 4): the PSVec/Plated-driven cata, ana, hylo (+ the cata(Unfold) overload, Coalg type, and the unfoldFold/unfoldCoalg/foldInPlace engines) are removed. Their erased positional indexing made algebra arity slips a runtime error — exactly what the typed path (cataF/anaF/hyloF + the zoo) fixes; with the zoo, fusion-as-cross, and the M-generic drivers landed, the untyped path no longer earns its surface. Core Plated is untouched (transform/rewrite/ universe live there, not here). Gone with it: SchemesSpec (the untyped suite), the cross-path equivalence example in SchemesFSpec, the eoCata/eoAna/eoHylo bench rows and their fixtures. schemes.md rewritten typed-first (the PSVec narrative replaced by a removal note); benchmarks.md schemes section consolidated around the typed rows (this commit also lands the 2026-06-12 zoo/fusion/M-path tables from CI run 27398242244). All 509 tests green. Co-Authored-By: Claude Fable 5 --- .../constructive/eo/bench/SchemesBench.scala | 27 +- .../eo/bench/fixture/SchemesFixtures.scala | 23 +- ...06-11-001-feat-biaffine-scheme-zoo-plan.md | 2 +- .../dev/constructive/eo/schemes/Schemes.scala | 288 ++---------------- .../eo/schemes/SchemesFSpec.scala | 17 +- .../constructive/eo/schemes/SchemesSpec.scala | 238 --------------- site/docs/benchmarks.md | 163 +++++----- site/docs/schemes.md | 262 ++-------------- 8 files changed, 138 insertions(+), 882 deletions(-) delete mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala 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 1e0db457..d4dedcc4 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -1,23 +1,21 @@ 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` — 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 `PSVec` heap machine. - * - **eoF** — the *typed* pattern-functor path (`cataF`/`anaF`/`hyloF` over `BinF` via a `Basis` - * + `Traverse[BinF]`), a `cats.Eval` trampoline. This row quantifies the typed path's - * allocation against droste's basic schemes — the U6 measurement that informs the - * Eval-vs-explicit-heap-machine driver decision. + * - **eoF** — the typed pattern-functor path (`cataF`/`anaF`/`hyloF` 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. @@ -46,10 +44,6 @@ class SchemesBench extends JmhDefaults: val fixTree: Fix[BinF] = balancedFix(Depth) // Prebuilt scheme optics / functions (construction not measured). - val eoCataG = Schemes.cata(eoSum) // Getter[Bin, Int] - val eoHyloG = Schemes.hylo(eoExpand, eoHyloAlg) // Getter[Int, Int] - val eoAnaR = Schemes.ana(eoAnaCoalg) // Review[Bin, Int] - // typed pattern-functor path (Eval trampoline over Traverse[BinF]) val eoCataFG = Schemes.cataF(eoTypedSum) // Getter[Bin, Int] val eoHyloFG = Schemes.hyloF(eoTypedCoalg, eoTypedHyloAlg) // Getter[Int, Int] @@ -60,19 +54,16 @@ class SchemesBench extends JmhDefaults: val drosteAnaF: Int => Fix[BinF] = scheme.ana(drosteBuild) // ----- cata: fold a prebuilt tree to its leaf-sum -------------------------- - @Benchmark def eoCata: Int = eoCataG.get(eoTree) @Benchmark def eoCataF: Int = eoCataFG.get(eoTree) @Benchmark def drosteCata: Int = drosteCataF(fixTree) @Benchmark def handCata: Int = handSum(eoTree) // ----- hylo: build + fold from a seed, fused (no intermediate tree) -------- - @Benchmark def eoHylo: Int = eoHyloG.get(Depth) @Benchmark def eoHyloF: Int = eoHyloFG.get(Depth) @Benchmark def drosteHylo: Int = drosteHyloF(Depth) @Benchmark def handHylo: Int = SchemesFixtures.handHylo(Depth) // ----- ana: build the tree from a seed (materializing) --------------------- - @Benchmark def eoAna: Bin = eoAnaR.reverseGet(Depth) @Benchmark def eoAnaF: Bin = eoAnaFR.reverseGet(Depth) @Benchmark def drosteAna: Fix[BinF] = drosteAnaF(Depth) @Benchmark def handAna: Bin = handBuild(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 faa21c3b..8b07f82d 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 @@ -6,8 +6,7 @@ import cats.{Applicative, Eval, Traverse} import higherkindness.droste.data.Fix import higherkindness.droste.{Algebra, Coalgebra} -import dev.constructive.eo.data.PSVec -import dev.constructive.eo.schemes.{Basis, Schemes} +import dev.constructive.eo.schemes.Basis /** Pattern functor for the native [[Bin]] tree (`Leaf(Int)` / `Node(Bin, Bin)`). * @@ -70,26 +69,6 @@ object SchemesFixtures: if d <= 0 then BinF.LeafF(1) else BinF.NodeF(d - 1, d - 1) } - // ----- eo algebra / coalgebra (over native Bin via Plated) ----------------- - - val eoSum: (Bin, PSVec[Int]) => Int = (node, kids) => - node match - case Bin.Leaf(v) => v - case Bin.Node(_, _) => kids(0) + kids(1) - - /** 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). - */ - 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) - - /** 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))) - // ----- eo TYPED algebras (over the pattern functor BinF via Basis/Traverse) ---------------- /** Typed cata gather — the leaf-sum, pattern-matching `BinF`'s named constructors. */ 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 index 7443cad4..2b1f94bd 100644 --- 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 @@ -79,7 +79,7 @@ with laws. 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. +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 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 de08c035..a7745d8f 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -1,56 +1,32 @@ package dev.constructive.eo package schemes -import scala.annotation.tailrec - -import java.util.ArrayDeque - import cats.{Monad, Traverse} -import data.{Forget, ForgetK, PSVec} -import optics.{Getter, Optic, Plated, Review, Unfold} +import data.{Forget, ForgetK} +import optics.{Getter, Optic, Review} -/** Recursion schemes as composable optics, built on the core optic surface. - * - * - [[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. +/** 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` — + * algebras pattern-match `F`'s *named constructors*, no positional indexing. * - * 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). + * - [[cataF]] folds (`CataF`, Getter-shaped); [[anaF]] builds (`AnaF`, Review-shaped); [[hyloF]] + * is the **fused** zero-`S` refold. `anaF(c).cross(cataF(a))` fuses (single pass, no full-tree + * retention). + * - The zoo: [[paraF]] (subterms paired from the walked nodes), [[apoF]] (O(1) graft), + * [[histoF]] / [[futuF]] (course-of-value / multi-layer, via [[Attr]] / [[Coattr]]). Decorated + * generically through the [[Decor]] optic family (gather/scatter over the `BiAffine` carrier) + * — zygo/dyna/chrono are user-written [[DecorGather]] / [[DecorScatter]] values fed to the + * generic [[cataF]] / [[anaF]] overloads. + * - The M-generic drivers [[cataFM]] / [[anaFM]] / [[hyloFM]] run the same machine lifted + * through `Monad[M].tailRecM` (effectful layers, single-pass linear Ms). * - * 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`. - * - * 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. + * All drivers run on one stack-safe engine family: a `< 512`-deep on-stack fast path falling back + * per deep subtree to a heap `ArrayDeque` machine ([[foldLayered]] and siblings) — 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. */ @@ -85,234 +61,6 @@ object Schemes: */ private object AscendToken - /** Engine for the *fold* schemes ([[cata]] / [[hylo]]). `expand` yields a node's children; - * `combine` folds a node plus its already-folded children — re-supplied the node, so it needs no - * per-node closure. Stack-safe for any *terminating* `expand` (past the on-stack limit the - * recursion lives on the heap, so a non-terminating `expand` exhausts the heap — - * `OutOfMemoryError` — rather than overflowing the stack). - */ - private def unfoldFold[N, R](expand: N => PSVec[N], combine: (N, PSVec[R]) => R): N => R = - - def heap(root: N): R = - final class Frame(val node: N, val kids: PSVec[N], val out: Array[AnyRef], var i: Int) - val stack = new java.util.ArrayDeque[Frame]() - var ret: AnyRef = null.asInstanceOf[AnyRef] - def enter(n: N): Unit = - val kids = expand(n) - if kids.isEmpty then ret = combine(n, PSVec.empty[R]).asInstanceOf[AnyRef] - else stack.push(new Frame(n, kids, new Array[AnyRef](kids.length), 0)) - enter(root) - while !stack.isEmpty do - 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 = combine(fr.node, PSVec.unsafeWrap[R](fr.out)).asInstanceOf[AnyRef] - val _ = stack.pop() - ret.asInstanceOf[R] - - def rec(n: N, depth: Int): R = - if depth >= OnStackLimit then heap(n) - else - val kids = expand(n) - val k = kids.length - if k == 0 then combine(n, PSVec.empty[R]) - else - val out = new Array[AnyRef](k) - var i = 0 - while i < k do - out(i) = rec(kids(i), depth + 1).asInstanceOf[AnyRef] - i += 1 - combine(n, PSVec.unsafeWrap[R](out)) - - n0 => rec(n0, 0) - - /** 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. - * - * 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)`. - * - * 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. - * - * 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)))) - // =========================================================================================== // Typed pattern-functor path — the opt-in, type-safe complement to the PSVec schemes above. // diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala index 9f954f62..e1728386 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala @@ -6,12 +6,11 @@ import scala.language.implicitConversions import cats.instances.int.given import org.specs2.mutable.Specification -import data.{Forget, PSVec} +import data.Forget import data.Forget.given -import optics.{Getter, Lens, Optic, Plated} +import optics.{Getter, Lens, Optic} import optics.Optic.* // get, andThen, cross, foldMap -import generics.plate import schemes.samples.{Bin, BinF, Rose, RoseF} /** Behaviour spec for the typed pattern-functor schemes (`cataF` / `anaF` / `hyloF`) and `fLayer`. @@ -150,18 +149,6 @@ class SchemesFSpec extends Specification: (Schemes.cataF(sumLeaves).get(Bin.Branch(Bin.Leaf(4), Bin.Leaf(5))) == 9) must beTrue } - // ----- cross-path equivalence to the #23 PSVec cata ----- - - "cataF agrees with the #23 Plated-driven cata on the same computation" >> { - given Plated[Bin] = plate[Bin] - val psvecSum: (Bin, PSVec[Int]) => Int = (node, kids) => - node match - case Bin.Leaf(n) => n - case Bin.Branch(_, _) => kids.toList.sum - (Schemes.cata(psvecSum).get(tree) == Schemes.cataF(sumLeaves).get(tree)) - .and(Schemes.cataF(sumLeaves).get(tree) == 6) - } - // ----- fLayer: the single-layer Forget[F] optic (R1) ----- "fLayer is a usable Optic[S,S,S,S,Forget[F]]: to/from round-trip one layer" >> { diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala deleted file mode 100644 index d849dccd..00000000 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ /dev/null @@ -1,238 +0,0 @@ -package dev.constructive.eo -package schemes - -import scala.annotation.tailrec - -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} - -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) - - // 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) - - // 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))) - - // 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 expr: Expr = Expr.Add(Expr.Lit(1.0), Expr.Mul(Expr.Lit(2.0), Expr.Lit(3.0))) - - "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 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 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-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 - } - - "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.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 - } - - "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 - } - - "the fused hylo Getter composes further into the pipeline" >> { - val toStr: Getter[Int, String] = - Schemes.hylo(expandFib, fusedFib).andThen(Getter[Double, String](_.toString)) - (toStr.get(3) == "4.0") 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 - } - - // 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 - } - - // ----- 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 - } - - "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 - } - - // ----- 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 - } - - "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 - } - - "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 - } - - // ----- 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'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 - } - - "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 - } diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index 0272ac45..563eba4e 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -434,87 +434,92 @@ 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. -<<<<<<< HEAD -======= -## Recursion schemes — cata / ana / hylo vs droste and hand-written - -`SchemesBench` measures cats-eo's typed recursion schemes against -[droste](https://github.com/higherkindness/droste) and a hand-written fold/unfold -over a fixed-size `Expr` tree (balanced binary tree, ~512 nodes). The typed -schemes (`cataF` / `anaF` / `hyloF`) use the `ArrayDeque`-based heap machine -that replaced the `Eval` trampoline — stack-safe to 10^6 nodes, no forking -required. - -| Method | ns/op | B/op | vs droste (ns) | vs hand (ns) | -|---|--:|--:|--:|--:| -| `handCata` | 13 170 | 0 | — | 1× | -| `handHylo` | 11 358 | 0 | — | 1× | -| `handAna` | 19 059 | 163 816 | — | 1× | -| `drosteCata` | 44 535 | 164 824 | 1× | 3.4× | -| `drosteHylo` | 76 215 | 328 641 | 1× | 6.7× | -| `drosteAna` | 55 247 | 327 632 | 1× | 2.9× | -| `eoCata` | 85 627 | 197 569 | 1.9× | 6.5× | -| `eoHylo` | 85 523 | 295 849 | 1.1× | 7.5× | -| `eoAna` | 144 313 | 786 297 | 2.6× | 7.6× | +## Recursion schemes — the typed path vs droste and hand-written -Three results: +`SchemesBench` measures the typed recursion schemes (`cataF` / `anaF` / `hyloF` and the +zoo — the `foldLayered` `ArrayDeque` machine, 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). -- **`cata` / `hylo` are ~1.9× / ~1.1× droste in ns** and ~1.2× / ~0.9× in B/op. - The hylo gap has closed to noise; cata carries a small constant from the typed - `CoAlgebra[F[_], A]` dispatch that droste's `Gather`-based scheme avoids. -- **`ana` is the weakest link** (~2.6× droste ns, ~2.4× B/op). The unfold leg - allocates a `Frame` per node to track the output position; this is the primary - allocation driver and the main gap to close. -- **All three are ~6–8× behind hand-written** in ns; hand-written `cata` / `hylo` - are essentially alloc-free (0 B/op) because the JIT fuses the in-place fold — - no intermediate carrier representation, no `Frame`. The B/op gap is the - structural cost of keeping the scheme compositional (optic-native) rather than - fused into a single recursive function. - -The earlier memory note "eo schemes ~15–20× slower than droste/hand" was from a -pre-optimisation spike on an untuned encoding; the current `ArrayDeque` heap -machine closes that to ~2× droste (cata) / parity (hylo). - -## Recursion schemes — `cata` / `ana` / `hylo`: eo, typed eo, droste, hand - -`SchemesBench` folds/builds a perfect binary tree of `2^12` (8 191 nodes) four ways: -**eo** (the `PSVec` Plated machine — `cata`/`ana`/`hylo` from `cats-eo-schemes`), -**eoF** (the *typed* pattern-functor path — `cataF`/`anaF`/`hyloF` over `BinF` via a `Basis` + -`Traverse[BinF]`), **droste** (`scheme.cata/ana/hylo` over `Fix[BinF]`), and **hand** (plain -recursion). Allocation is the trustworthy signal here (`gc.alloc.rate.norm`, deterministic and -box-independent — ns/op on the shared box is too noisy to compare). - -| Scheme | eo (PSVec) | eoF (typed) | droste basic | hand | eoF ÷ droste | -|---|--:|--:|--:|--:|--:| -| `cata` | 197 568 | 361 387 | 164 824 | 0.045 | 2.2× | -| `hylo` | 295 849 | 361 386 | 328 641 | 0.153 | 1.1× | -| `ana` | 589 713 | 524 194 | 327 632 | 163 816 | 1.6× | - -(B/op at depth 12.) - -Both the `PSVec` and typed paths run on the **same `< 512`-on-stack / heap-`ArrayDeque` hybrid** as -`Plated.transform` — no `cats.Eval` trampoline. Two readings: - -- **The `PSVec` path is droste-competitive.** eo `cata` is ~1.2× droste, `hylo` *beats* droste - (0.9×, the fused refold builds no intermediate tree), `ana` ~1.8×. The constant is carrier - materialisation (one `PSVec` + `out` array per node), same as Plated above. -- **The typed path is now ~1.1–2.2× droste basic** — `hylo` at parity, `ana` 1.6× (it even beats - eo's own `PSVec` `ana`), `cata` 2.2×. The typed driver walks the deep recursion with the array - machine and uses the user's `Traverse[F]` only *per layer* (bounded fanout: `foldLeft` to read a - node's children, `map` to rebuild the typed `F[result]` the algebra destructures); leaf nodes - skip the rebuild via a phantom recast. Earlier this path used a `cats.Eval` trampoline and cost - ~8–16× droste (~316 B/node of `Eval` machinery) — replacing it with the machine cut allocation - ~7×. The residual `cata` gap is **inherent, not waste**: eo folds a *native* `Bin`, so `project` - allocates a `BinF[Bin]` layer per node, where droste folds a `Fix[BinF]` and its `unfix` is free — - the same native-vs-`Fix` cost eo's `PSVec` `cata` pays (197 568). And eoF buys **type-safety + - stack-safety** that droste's *basic* schemes lack (naive recursion, not optic-composable), so the - comparison is not apples-to-apples. - -**Decision (U6):** the typed driver is the `foldLayered` **heap machine** (the pre-planned -explicit-machine option), *not* a trampoline — it reaches allocation parity-to-~2× with droste basic -while staying typed and stack-safe to 10⁶ in the default heap. Reach for the `PSVec` `cata`/`ana`/ -`hylo` when you want zero boilerplate, and `cataF`/`anaF`/`hyloF` when you want named-constructor -type-safety at near-droste allocation. +Core rows (run 27398242244, 2026-06-12; B/op is the trustworthy metric on the shared +runner): + +| 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× | +| `eoCataF` | 361 385 | 2.2× | +| `eoHyloF` | 361 385 | 1.1× | +| `eoAnaF` | 524 193 | 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 (`paraF` / `apoF` / `histoF` / `futuF`) against +`droste.scheme.zoo` — plus the routes that pin the driver's design decisions: the generic +decoration route, the monadic machine at `cats.Id`, and fused-vs-materialised `cross`. +As above, B/op is the trustworthy column; ns/op is directional. + +| Method | ns/op | B/op | B/op vs droste | +|---|--:|--:|--:| +| `eoPara` | 183 747 | 557 945 | 0.50× | +| `drostePara` | 283 752 | 1 114 890 | 1× | +| `eoApo` | 195 677 | 655 249 | 0.57× | +| `drosteApo` | 293 721 | 1 146 674 | 1× | +| `eoApoGraft` | 35 | 224 | 0.88× | +| `drosteApoGraft` | 46 | 256 | 1× | +| `eoHisto` | 191 617 | 557 969 | 1.24× | +| `drosteHisto` | 103 420 | 448 705 | 1× | +| `eoFutu` | 188 749 | 655 249 | 1.43× | +| `drosteFutu` | 93 458 | 458 689 | 1× | +| `eoCataF` | 172 428 | 361 385 | 2.19× | +| `eoCataGenericRoute` | 162 279 | 362 313 | 2.20× | +| `drosteCata` | 56 542 | 164 824 | 1× | +| `eoHyloF` | 180 767 | 361 385 | — | +| `eoHyloM` | 343 202 | 929 472 | — | +| `eoCrossFused` | 239 393 | 820 066 | — | +| `eoCrossMaterialized` | 375 824 | 885 579 | — | + +Six results: + +- **`para` / `apo` halve droste's allocation.** eo decorates on the same array machine as + `cataF`/`anaF`, pairing subterms off the already-walked nodes; droste's zoo re-embeds each + subterm (para) and re-allocates the `Either` spine (apo), landing at ~2× eo's B/op + (1 114 890 vs 557 945; 1 146 674 vs 655 249). The ns column agrees directionally + (~1.5× in eo's favour on both). +- **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 (224 vs 256), + 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`. +- **The generic decoration route costs nothing.** A user-written identity gather — which skips + the driver's identity fast path — lands at 362 313 B/op vs the fast path's 361 385: escape + analysis elides the per-node decoration wrapper, so writing your own `Decor` route is + alloc-free over `cataF`. +- **`histo` / `futu` trail droste by ~1.2–1.4× 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 + 929 472 B/op vs 361 385 for `hyloF` (~2.6×) — that delta is the `tailRecM` step-event + wrapping, the price of arbitrary-monad algebras. This run includes the M-machine + optimisation: the previous run (27384569800) had `eoHyloM` at 1 606 586 B/op, a **−42%** + improvement. +- **Fused `cross` beats materialising on both axes.** Composing `anaF` into `cataF` via + `cross` fuses into one pass — 239 393 ns / 820 066 B/op vs 375 824 ns / 885 579 B/op for + build-the-tree-then-fold (~1.6× faster, no intermediate tree). The fused path also dropped + **−22%** from the previous run's 1 049 417 B/op with the same optimisation commit. ## Reproducing diff --git a/site/docs/schemes.md b/site/docs/schemes.md index f18c4c5e..945bc46e 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -1,252 +1,36 @@ # 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. If you want **named-constructor type safety**, the opt-in typed pattern-functor -> path at the bottom of this page (`cataF`/`anaF`/`hyloF`) gives it — at the cost of writing a -> pattern functor `F` and its `Traverse`/`Project`/`Embed`. The two paths complement each other; -> this `PSVec` path stays the zero-boilerplate default. -> -> 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` | -| `ana` | `Review[S, Seed]` | build an `S` from a seed | -| `hylo` | `Getter[Seed, A]` (**fused** — no intermediate `S`) | unfold-and-fold in one pass | - -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. +| `cataF` | `CataF` (Getter-shaped) | fold an existing `S` to an `A` | +| `anaF` | `AnaF` (Review-shaped) | build an `S` from a seed | +| `hyloF` | `Getter[Seed, A]` (**fused** — no intermediate `S`) | unfold-and-fold in one pass | +| `paraF` / `apoF` / `histoF` / `futuF` | the zoo (below) | decorated folds / unfolds | +| `cataFM` / `anaFM` / `hyloFM` | `Forget[M]`-carried | effectful steps in a `Monad[M]` | -```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 algebra sees each node plus its already-folded children. Here, sum every number anywhere -in a JSON document: - -```scala mdoc:silent -val sumNumbers: Getter[Json, Int] = - Schemes.cata[Json, Int]((node, folded) => - node.asNumber.flatMap(_.toInt).getOrElse(0) + folded.toList.sum - ) - -val doc = Json.obj( - "a" -> Json.fromInt(1), - "b" -> Json.arr(Json.fromInt(2), Json.fromInt(3)), - "c" -> Json.fromString("ignored"), -) -``` - -```scala mdoc -sumNumbers.get(doc) -``` - -Because `cata` is a `Getter`, it composes onto any optic that ends in the recursive type: - -```scala mdoc:silent -final case class Payload(label: String, body: Json) -val bodySum: Getter[Payload, Int] = - Getter[Payload, Json](_.body).andThen(sumNumbers) -``` - -```scala mdoc -bodySum.get(Payload("p", doc)) -``` - -### 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: - -```scala mdoc:silent -import dev.constructive.eo.optics.{Review, Unfold} - -// node-blind algebra (counts nodes) carried as a build-only optic … -val sizeAlg = Unfold.algebra[Int, Int, PSVec](kids => 1 + kids.toList.sum) - -// … and a per-layer post-processing step composed in front of it -val weighted = Review[Int, Int](_ * 2).andThen(sizeAlg) - -val docSize: Getter[Json, Int] = Schemes.cata[Json, Int](sizeAlg) -``` - -```scala mdoc -docSize.get(doc) -``` - -One honesty note: a `PSVec` layer is **node-blind** — the children arrive positionally -with the constructor erased — so a pure `PSVec`-algebra can only express -constructor-independent folds (sizes, counts, child aggregations). Folds that dispatch -on the node (like `sumNumbers` above) use the para-flavored `(S, PSVec[A]) => A` -overload, which stays the primary form on this untyped path. - -## `ana` — an unfold that is a `Review` +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⁶. -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: +> 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. ```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))) - } -``` - -```scala mdoc -buildTree.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: - -```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, - ) -``` - -```scala mdoc -countLeaves.get(2) -countLeaves.get(20) // stack-safe; no 2^20-node tree is materialised -``` - -## `cross` — build, then read (structure-preserving) - -The core `cross` combinator joins a *build* optic to a *read* optic at their shared middle type. -It is `self.reverse.andThen(that)`: it flips the reversible builder so it reads what it would have -built, then composes. The result is a **full `Optic`**, not a collapsed getter — its read -capability follows the composed carrier (`.get` through a Getter, `.getOption` through a Prism, -`.foldMap` through a Fold), and it works **cross-carrier** via `Morph`. With `ana` and `cata` it -is exactly the **materializing** hylo (`cata ∘ ana` — it *does* build the `S`): - -```scala mdoc:silent -val refoldSum = buildTree.cross(sumNumbers) // Optic[Int, Unit, Int, Unit, Direct] -``` - -```scala mdoc -refoldSum.get(2) -``` - -`refoldSum` and the fused `countLeaves` compute the same value (the **hylo law**, -`hylo == cata ∘ ana`); the difference is that the fused `hylo` never allocates the intermediate -`Json`, while `ana.cross(cata)` builds it and then folds. Reach for the fused `hylo` when the -intermediate structure is large; reach for `ana` / `cata` / `cross` when you want the -intermediate `S`, or want to drop the schemes into a larger optic pipeline. - -`cross` is overloaded (like `andThen`): a trait-member overload composes under a single carrier, -and a `Morph`-bridged overload (same name) composes *across* carriers — overload resolution picks -the right one. So crossing a builder with a **`Fold`** (not just a single-focus getter) bridges -`Direct → Forget` via `Morph` and reads *many* foci from what was built — read-many falls out of -`cross`: - -```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]) -``` - -```scala mdoc -sumBuilt.foldMap[Int](identity)(4) // 1+2+3+4 +import dev.constructive.eo.schemes.Schemes +import dev.constructive.eo.optics.Getter ``` -## Checking the hylo law — `cats-eo-schemes-laws` - -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: - -```scala -libraryDependencies += "dev.constructive" %% "cats-eo-schemes-laws" % "@VERSION@" % Test -``` +## The pattern-functor setup — `cataF` / `anaF` / `hyloF` -`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). - -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. - -## Typed pattern-functor schemes — `cataF` / `anaF` / `hyloF` - -The schemes above thread children through `PSVec[AnyRef]`: fast, but the algebra indexes them -positionally (`kids(0)`, `kids(1)`), so an arity slip is a runtime error. The **typed** path trades -a little boilerplate for compile-time safety: 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** instead. +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**. 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. @@ -328,10 +112,10 @@ countLeavesF.get(3) // same count, fused — no Bin materiali countLeavesF.get(1000000) // stack-safe: the heap machine, O(depth) heap ``` -Like their `PSVec` counterparts, `cataF`/`hyloF` are `Getter`s and `anaF` is a `Review`, so +`cataF`/`hyloF` are Getter-shaped and `anaF` is Review-shaped, so they compose with the rest of the optic algebra via `andThen` and `cross` (the materializing `anaF(…).cross(cataF(…))` equals the fused `hyloF` for a pure algebra — the hylo law). They run on -the **same `< 512`-on-stack / heap-`ArrayDeque` machine** as the `PSVec` schemes (no `cats.Eval` +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)). **Choosing a path:** reach for `cata`/`ana`/`hylo` (default) when you From b6bea247a8c373c85981995e529ffabaeceafd37 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 09:52:43 +0200 Subject: [PATCH 20/61] =?UTF-8?q?refactor(schemes)!:=20strip=20the=20F=20s?= =?UTF-8?q?uffix=20=E2=80=94=20the=20typed=20schemes=20own=20the=20bare=20?= =?UTF-8?q?names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the untyped path gone the F marker is vestigial: cataF/anaF/hyloF → cata/ana/hylo; paraF/apoF/histoF/futuF → para/apo/histo/futu; the M family cataFM/anaFM/hyloFM → cataM/anaM/hyloM; classes CataF/AnaF → Cata/Ana, CataFM/AnaFM/FoldFM → CataM/AnaM/FoldM. Spec files follow (SchemesFSpec → SchemesSpec, SchemesFLawsSpec → SchemesLawsSpec, SchemesFMSpec → SchemesMSpec), as do the bench rows (eoCataF → eoCata — the droste*F vals keep their F-for-function suffix) and the site docs. Decor.cata/Decor.ana coexist with Schemes.cata/ana (different objects). docs/plans + docs/brainstorms keep the old names as historical record. All 509 tests green; mdoc clean. Co-Authored-By: Claude Fable 5 --- .../constructive/eo/bench/SchemesBench.scala | 36 +++---- .../dev/constructive/eo/schemes/Basis.scala | 4 +- .../constructive/eo/schemes/Citizens.scala | 20 ++-- .../constructive/eo/schemes/CitizensM.scala | 32 +++---- .../dev/constructive/eo/schemes/Decor.scala | 8 +- .../dev/constructive/eo/schemes/Schemes.scala | 94 +++++++++---------- .../eo/schemes/DecorLawsSpec.scala | 46 ++++----- .../constructive/eo/schemes/DecorSpec.scala | 4 +- .../constructive/eo/schemes/FusionSpec.scala | 26 ++--- ...sFLawsSpec.scala => SchemesLawsSpec.scala} | 24 ++--- ...SchemesFMSpec.scala => SchemesMSpec.scala} | 60 ++++++------ .../{SchemesFSpec.scala => SchemesSpec.scala} | 88 ++++++++--------- .../eo/schemes/SchemesZooSpec.scala | 70 +++++++------- .../eo/schemes/samples/PatternFunctors.scala | 5 +- site/docs/benchmarks.md | 18 ++-- site/docs/schemes.md | 72 +++++++------- 16 files changed, 303 insertions(+), 304 deletions(-) rename schemes/src/test/scala/dev/constructive/eo/schemes/{SchemesFLawsSpec.scala => SchemesLawsSpec.scala} (81%) rename schemes/src/test/scala/dev/constructive/eo/schemes/{SchemesFMSpec.scala => SchemesMSpec.scala} (74%) rename schemes/src/test/scala/dev/constructive/eo/schemes/{SchemesFSpec.scala => SchemesSpec.scala} (70%) 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 d4dedcc4..9e946887 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -13,7 +13,7 @@ import dev.constructive.eo.schemes.Schemes /** Recursion schemes — `cata` / `ana` / `hylo` — four ways, on the same workload: * - * - **eoF** — the typed pattern-functor path (`cataF`/`anaF`/`hyloF` over `BinF` via a `Basis` + * - **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 @@ -45,38 +45,38 @@ class SchemesBench extends JmhDefaults: // Prebuilt scheme optics / functions (construction not measured). // typed pattern-functor path (Eval trampoline over Traverse[BinF]) - val eoCataFG = Schemes.cataF(eoTypedSum) // Getter[Bin, Int] - val eoHyloFG = Schemes.hyloF(eoTypedCoalg, eoTypedHyloAlg) // Getter[Int, Int] - val eoAnaFR = Schemes.anaF[BinF, Int, Bin](eoTypedCoalg) // Review[Bin, Int] + 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) val drosteAnaF: Int => Fix[BinF] = scheme.ana(drosteBuild) // ----- cata: fold a prebuilt tree to its leaf-sum -------------------------- - @Benchmark def eoCataF: Int = eoCataFG.get(eoTree) + @Benchmark def eoCata: Int = eoCataG.get(eoTree) @Benchmark def drosteCata: Int = drosteCataF(fixTree) @Benchmark def handCata: Int = handSum(eoTree) // ----- hylo: build + fold from a seed, fused (no intermediate tree) -------- - @Benchmark def eoHyloF: Int = eoHyloFG.get(Depth) + @Benchmark def eoHylo: Int = eoHyloG.get(Depth) @Benchmark def drosteHylo: Int = drosteHyloF(Depth) @Benchmark def handHylo: Int = SchemesFixtures.handHylo(Depth) // ----- ana: build the tree from a seed (materializing) --------------------- - @Benchmark def eoAnaF: Bin = eoAnaFR.reverseGet(Depth) + @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.paraF[BinF, Bin, Int](eoParaAlg) + val eoParaG = Schemes.para[BinF, Bin, Int](eoParaAlg) val drosteParaFn: Fix[BinF] => Int = scheme.zoo.para(drosteParaAlg) - val eoApoR = Schemes.apoF[BinF, Int, Bin](eoApoCoalg) + val eoApoR = Schemes.apo[BinF, Int, Bin](eoApoCoalg) val drosteApoFn: Int => Fix[BinF] = scheme.zoo.apo(drosteApoCoalg) - val eoHistoG = Schemes.histoF[BinF, Bin, Int](eoHistoAlg) + val eoHistoG = Schemes.histo[BinF, Bin, Int](eoHistoAlg) val drosteHistoFn: Fix[BinF] => Int = scheme.zoo.histo(drosteHistoAlg) - val eoFutuR = Schemes.futuF[BinF, Int, Bin](eoFutuCoalg) + val eoFutuR = Schemes.futu[BinF, Int, Bin](eoFutuCoalg) val drosteFutuFn: Int => Fix[BinF] = scheme.zoo.futu(drosteFutuCoalg) @Benchmark def eoPara: Int = eoParaG.get(eoTree) @@ -95,7 +95,7 @@ class SchemesBench extends JmhDefaults: // guarantee; the O(graft) re-walk contrast applies to the GENERIC distApo // route (Decor.apo), not to droste.zoo.apo. - val eoApoGraftR = Schemes.apoF[BinF, Int, Bin] { d => + 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( @@ -109,20 +109,20 @@ class SchemesBench extends JmhDefaults: // ----- fused cross vs materialized composition (the fusion law's alloc pin) -- - val eoCrossFusedG = Schemes.anaF[BinF, Int, Bin](eoTypedCoalg).cross(Schemes.cataF(eoTypedSum)) + val eoCrossFusedG = Schemes.ana[BinF, Int, Bin](eoTypedCoalg).cross(Schemes.cata(eoTypedSum)) @Benchmark def eoCrossFused: Int = eoCrossFusedG.get(Depth) - @Benchmark def eoCrossMaterialized: Int = eoCataFG.get(eoAnaFR.reverseGet(Depth)) + @Benchmark def eoCrossMaterialized: Int = eoCataG.get(eoAnaR.reverseGet(Depth)) // ----- generic decoration route (user-written Decor, no identity fast path) -- - val eoCataGenericG = Schemes.cataF[BinF, Bin, Int, Int](userIdGather)(eoTypedSum) + val eoCataGenericG = Schemes.cata[BinF, Bin, Int, Int](userIdGather)(eoTypedSum) @Benchmark def eoCataGenericRoute: Int = eoCataGenericG.get(eoTree) // ----- the M path at Id: the tailRecM-lifted machine's per-event floor ------ - val eoHyloFMRunner = - Schemes.hyloFM[cats.Id, BinF, Int, Int](d => eoTypedCoalg(d), (s, fa) => eoTypedHyloAlg(s, fa)) + val eoHyloMRunner = + Schemes.hyloM[cats.Id, BinF, Int, Int](d => eoTypedCoalg(d), (s, fa) => eoTypedHyloAlg(s, fa)) - @Benchmark def eoHyloM: Int = eoHyloFMRunner.run(Depth) + @Benchmark def eoHyloM: Int = eoHyloMRunner.run(Depth) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala index 5e90c73b..becd2046 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala @@ -2,8 +2,8 @@ package dev.constructive.eo package schemes /** The user-supplied bridge between a recursive type `S` and one *layer* of its **pattern functor** - * `F[_]`, for the typed recursion-scheme path ([[Schemes.cataF]] / [[Schemes.anaF]] / - * [[Schemes.hyloF]]). + * `F[_]`, for the typed recursion-scheme path ([[Schemes.cata]] / [[Schemes.ana]] / + * [[Schemes.hylo]]). * * A pattern functor replaces `S`'s recursive positions with a type parameter: * {{{ diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala index 4f14d90b..00a0af34 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala @@ -6,7 +6,7 @@ import cats.Traverse import data.Direct import optics.{Getter, Optic} -/** Concrete scheme citizens — `cataF`/`anaF` return these instead of bare `Getter`/`Review` so +/** Concrete scheme citizens — `cata`/`ana` return these instead of bare `Getter`/`Review` so * composition can **fuse**: the classes carry their (co)algebra and instances as data, and the * fused `cross` overload below resolves on the concrete types. * @@ -14,14 +14,14 @@ import optics.{Getter, Optic} * perf-pinned encoding stays untouched): full generic composition via the trait members, plus * `.get` / `.reverseGet` as stored fields, the use-site-friendly shape. * - * Widening hazard, documented: binding an `AnaF` to a wider type (`Review`-shaped `Optic`) loses + * Widening hazard, documented: binding an `Ana` to a wider type (`Review`-shaped `Optic`) loses * the fused `cross` overload — the generic trait `cross` still typechecks and is extensionally - * equal, but materializes the full intermediate structure. `Schemes.hyloF(coalg, alg)` stays the + * equal, but materializes the full intermediate structure. `Schemes.hylo(coalg, alg)` stays the * always-fused spelling. */ /** Fold-scheme citizen: Getter-shaped, carrying the node-supplied algebra for fusion. */ -final class CataF[F[_], S, A] private[schemes] ( +final class Cata[F[_], S, A] private[schemes] ( val get: S => A, private[schemes] val alg: (S, F[A]) => A, ) extends Optic[S, Unit, A, Unit, Direct]: @@ -31,12 +31,12 @@ final class CataF[F[_], S, A] private[schemes] ( def from(d: Direct[X, Unit]): Unit = () /** View as a plain [[Getter]] — re-enters Getter's fused composition fast paths (and resolves the - * read-compose overload tie an unascribed `getter.andThen(cataF(...))` can hit). + * read-compose overload tie an unascribed `getter.andThen(cata(...))` can hit). */ def asGetter: Getter[S, A] = Getter(get) /** Unfold-scheme citizen: Review-shaped, carrying the coalgebra + instances for fusion. */ -final class AnaF[F[_], Seed, S] private[schemes] ( +final class Ana[F[_], Seed, S] private[schemes] ( val reverseGet: Seed => S, private[schemes] val coalg: Seed => F[Seed], )(using @@ -58,12 +58,12 @@ final class AnaF[F[_], Seed, S] private[schemes] ( * folded immediately, and released as the fold ascends — **no full-tree retention, no second * traversal** (the materializing spelling builds all of `S`, then folds it). The algebra is * node-supplied, so per-node construction is semantically required; a node-*blind* computation - * should use `Schemes.hyloF(coalg, alg)`, the zero-`S` spelling. + * should use `Schemes.hylo(coalg, alg)`, the zero-`S` spelling. * * Resolution: strictly more specific than the generic trait `cross`, so concrete-typed - * `anaF(c).cross(cataF(a))` lands here (pinned by an ascription test); widened operands fall - * back to the generic, materializing route — extensionally equal, allocation-different. + * `ana(c).cross(cata(a))` lands here (pinned by an ascription test); widened operands fall back + * to the generic, materializing route — extensionally equal, allocation-different. */ - def cross[A](inner: CataF[F, S, A]): Getter[Seed, A] = + def cross[A](inner: Cata[F, S, A]): Getter[Seed, A] = val machine: Seed => (S, A) = Schemes.fusedPairedFold(coalg, inner.alg)(using F, E) Getter[Seed, A](seed => machine(seed)._2) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala index 7e8b7ce4..1bf5dc5e 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala @@ -13,28 +13,28 @@ import optics.Optic * * Consumption: effect Ms (IO, State, …) have no `Foldable`, so the Foldable-gated Fold operations * (`.foldMap`/`.headOption`) and ReadCompose cells do NOT apply — the public consumption surface - * is the stored [[FoldFM.run]] (not raw `.to`). An Accessor-into-M capability is follow-up + * is the stored [[FoldM.run]] (not raw `.to`). An Accessor-into-M capability is follow-up * material. * * 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. See the linear-M boundary - * test in `SchemesFMSpec`. A persistent-state variant is deferred until a real consumer needs one. + * test in `SchemesMSpec`. A persistent-state variant is deferred until a real consumer needs one. * - * Re-forcing the same `M[A]` value returned by [[FoldFM.run]] is safe — each force allocates its + * Re-forcing the same `M[A]` value returned by [[FoldM.run]] is safe — each force allocates its * own fresh mutable state (the frame deque is allocated inside the `M`, not before it). Concurrent * forcing of a single `M[A]` value remains unsupported; each `run(s)` call is independent. * - * Widening hazard (the M-path mirror of `AnaF.cross`'s): a widened `AnaFM` still typechecks - * through the generic trait `andThen` via `assocForgetMonad` — extensionally equal but - * MATERIALIZING (`M[S]` built, then folded). `Schemes.hyloFM` stays the always-fused M spelling; - * the fused member below requires the concrete types. + * Widening hazard (the M-path mirror of `Ana.cross`'s): a widened `AnaM` still typechecks through + * the generic trait `andThen` via `assocForgetMonad` — extensionally equal but MATERIALIZING + * (`M[S]` built, then folded). `Schemes.hyloM` stays the always-fused M spelling; the fused member + * below requires the concrete types. */ -/** Generic effectful-fold citizen: `run: S => M[A]`. What `hyloFM` and the fused - * `AnaFM.andThen(CataFM)` return. +/** Generic effectful-fold citizen: `run: S => M[A]`. What `hyloM` and the fused + * `AnaM.andThen(CataM)` return. */ -sealed class FoldFM[M[_], S, A] private[schemes] (val run: S => M[A]) +sealed class FoldM[M[_], S, A] private[schemes] (val run: S => M[A]) extends Optic[S, Unit, A, Unit, Forget[M]]: type X = Nothing @@ -42,29 +42,29 @@ sealed class FoldFM[M[_], S, A] private[schemes] (val run: S => M[A]) def from(d: Forget[M][X, Unit]): Unit = () /** Effectful fold-scheme citizen: carries its algebra for fusion. */ -final class CataFM[M[_], F[_], S, A] private[schemes] ( +final class CataM[M[_], F[_], S, A] private[schemes] ( run: S => M[A], private[schemes] val algM: (S, F[A]) => M[A], -) extends FoldFM[M, S, A](run) +) extends FoldM[M, S, A](run) /** Effectful unfold-scheme citizen: `run: Seed => M[S]`, carrying the coalgebra + instances for * fusion. */ -final class AnaFM[M[_], F[_], Seed, S] private[schemes] ( +final class AnaM[M[_], F[_], Seed, S] private[schemes] ( run: Seed => M[S], private[schemes] val coalgM: Seed => M[F[Seed]], )(using private[schemes] val M: Monad[M], private[schemes] val F: Traverse[F], private[schemes] val E: Embed[F, S], -) extends FoldFM[M, Seed, S](run): +) extends FoldM[M, Seed, S](run): /** The fused M seam — here `andThen` genuinely is the focus seam (`Forget[M]` Kleisli). One * single-pass machine in `M` (the paired fold lifted through `tailRecM`): each node built once, * folded immediately — no `M[S]` materialization of the whole structure. Requires the concrete * types; widened operands fall back to the generic materializing `andThen`. */ - def andThen[A](inner: CataFM[M, F, S, A]): FoldFM[M, Seed, A] = + def andThen[A](inner: CataM[M, F, S, A]): FoldM[M, Seed, A] = val machine: Seed => M[(S, A)] = Schemes.fusedPairedFoldM(coalgM, inner.algM)(using M, F, E) - new FoldFM[M, Seed, A](seed => M.map(machine(seed))(_._2)) + new FoldM[M, Seed, A](seed => M.map(machine(seed))(_._2)) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala index 5a39c839..6f0fd263 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala @@ -67,9 +67,9 @@ enum Coattr[F[_], A]: // // NOTE (generic vs native routes): `Decor.apo`'s generic scatter unrolls a grafted subtree // through `Project` (droste's distApo — O(graft)). The O(1) graft is the privilege of the -// NATIVE `apoF` engine, which prefills result slots from apo's `Done` directly and never +// NATIVE `apo` engine, which prefills result slots from apo's `Done` directly and never // consults this value. Likewise `Decor.para`'s generic gather re-embeds the subterm it pairs -// (droste's Gather.para); the native `paraF` pairs subterms from the walked nodes instead. +// (droste's Gather.para); the native `para` pairs subterms from the walked nodes instead. // =========================================================================================== /** Fold-side decoration: gather-only (build-only member). `from` = gather. */ @@ -123,7 +123,7 @@ object Decor: /** Paramorphism decoration: each child slot pairs the original subterm with its result. * * Generic-route honesty: this gather *re-embeds* the subterm from the layer (droste's - * `Gather.para`) — the native `paraF` avoids that by pairing subterms from the nodes the machine + * `Gather.para`) — the native `para` avoids that by pairing subterms from the nodes the machine * already walks. */ def para[F[_]: Functor, S, A](using E: Embed[F, S]): DecorGather[F, (S, A), A] = @@ -169,7 +169,7 @@ object Decor: /** Apomorphism decoration, generic route: `Right(seed)` keeps unfolding, `Left(s)` answers with * the grafted subtree's projected layer — distApo, O(graft) through `Project`. The O(1) graft is - * the native `apoF` engine's privilege; it never consults this value. + * the native `apo` engine's privilege; it never consults this value. */ def apo[F[_]: Functor, S, A](using P: Project[F, S]): DecorScatter[F, Either[S, A], A] = new Optic[Either[S, A], Either[S, A], A, A, BiAffine]: 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 a7745d8f..fc540b73 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -10,16 +10,16 @@ import optics.{Getter, Optic, Review} * `Traverse[F]`) and the [[Basis]] (`Project`/`Embed`) correspondence to the recursive type `S` — * algebras pattern-match `F`'s *named constructors*, no positional indexing. * - * - [[cataF]] folds (`CataF`, Getter-shaped); [[anaF]] builds (`AnaF`, Review-shaped); [[hyloF]] - * is the **fused** zero-`S` refold. `anaF(c).cross(cataF(a))` fuses (single pass, no full-tree + * - [[cata]] folds (`Cata`, Getter-shaped); [[ana]] builds (`Ana`, Review-shaped); [[hylo]] is + * the **fused** zero-`S` refold. `ana(c).cross(cata(a))` fuses (single pass, no full-tree * retention). - * - The zoo: [[paraF]] (subterms paired from the walked nodes), [[apoF]] (O(1) graft), - * [[histoF]] / [[futuF]] (course-of-value / multi-layer, via [[Attr]] / [[Coattr]]). Decorated - * generically through the [[Decor]] optic family (gather/scatter over the `BiAffine` carrier) - * — zygo/dyna/chrono are user-written [[DecorGather]] / [[DecorScatter]] values fed to the - * generic [[cataF]] / [[anaF]] overloads. - * - The M-generic drivers [[cataFM]] / [[anaFM]] / [[hyloFM]] run the same machine lifted - * through `Monad[M].tailRecM` (effectful layers, single-pass linear Ms). + * - The zoo: [[para]] (subterms paired from the walked nodes), [[apo]] (O(1) graft), [[histo]] / + * [[futu]] (course-of-value / multi-layer, via [[Attr]] / [[Coattr]]). Decorated generically + * through the [[Decor]] optic family (gather/scatter over the `BiAffine` carrier) — + * zygo/dyna/chrono are user-written [[DecorGather]] / [[DecorScatter]] values fed to the + * generic [[cata]] / [[ana]] overloads. + * - The M-generic drivers [[cataM]] / [[anaM]] / [[hyloM]] run the same machine lifted through + * `Monad[M].tailRecM` (effectful layers, single-pass linear Ms). * * All drivers run on one stack-safe engine family: a `< 512`-deep on-stack fast path falling back * per deep subtree to a heap `ArrayDeque` machine ([[foldLayered]] and siblings) — stack-safe to @@ -219,14 +219,14 @@ object Schemes: // // Supported Ms are SINGLE-PASS and LINEAR: the machine'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 SchemesFMSpec. + // 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/apoFM follow-up supplies Left answers with no re-architecture. + // elgot/apoM follow-up supplies Left answers with no re-architecture. // =========================================================================================== /** The lifted machine. One `M`-action per `tailRecM` iteration: `Down(n)` runs `expandOr`, exits @@ -287,12 +287,12 @@ object Schemes: } /** Effectful catamorphism — the algebra runs in `M` (`(S, F[A]) => M[A]`); the layer peel stays - * the pure `Project`. Returns the `Forget[M]`-carried [[CataFM]] citizen; consume via `.run`. + * the pure `Project`. Returns the `Forget[M]`-carried [[CataM]] citizen; consume via `.run`. */ - def cataFM[M[_], F[_], S, A]( + def cataM[M[_], F[_], S, A]( algM: (S, F[A]) => M[A] - )(using M: Monad[M], F: Traverse[F], P: Project[F, S]): CataFM[M, F, S, A] = - new CataFM[M, F, S, A]( + )(using M: Monad[M], F: Traverse[F], P: Project[F, S]): CataM[M, F, S, A] = + new CataM[M, F, S, A]( foldLayeredM[M, F, S, A]( s => M.pure(Right(P.project(s))), (s, fs, out) => algM(s, rebuildLayer[F, S, A](fs, out)), @@ -301,13 +301,13 @@ object Schemes: ) /** Effectful anamorphism — the coalgebra (the layer producer) runs in `M` (`Seed => M[F[Seed]]`, - * the arbo `GetSellOptions` shape: fetching children is effectful). Returns the [[AnaFM]] - * citizen; consume via `.run`, fuse via `.andThen(cataFM(...))`. + * the arbo `GetSellOptions` shape: fetching children is effectful). Returns the [[AnaM]] + * citizen; consume via `.run`, fuse via `.andThen(cataM(...))`. */ - def anaFM[M[_], F[_], Seed, S]( + def anaM[M[_], F[_], Seed, S]( coalgM: Seed => M[F[Seed]] - )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): AnaFM[M, F, Seed, S] = - new AnaFM[M, F, Seed, S]( + )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): AnaM[M, F, Seed, S] = + new AnaM[M, F, Seed, S]( foldLayeredM[M, F, Seed, S]( seed => M.map(coalgM(seed))(Right(_)), (_, fSeed, out) => M.pure(E.embed(rebuildLayer[F, Seed, S](fSeed, out))), @@ -318,18 +318,18 @@ object Schemes: /** Effectful hylomorphism — the always-fused M spelling (what the D6 `eoHyloM` bench row runs): * `Seed => M[A]` with **no intermediate `S`**, seed-typed algebra. */ - def hyloFM[M[_], F[_], Seed, A]( + def hyloM[M[_], F[_], Seed, A]( coalgM: Seed => M[F[Seed]], algM: (Seed, F[A]) => M[A], - )(using M: Monad[M], F: Traverse[F]): FoldFM[M, Seed, A] = - new FoldFM[M, Seed, A]( + )(using M: Monad[M], F: Traverse[F]): FoldM[M, Seed, A] = + new FoldM[M, Seed, A]( foldLayeredM[M, F, Seed, A]( seed => M.map(coalgM(seed))(Right(_)), (seed, fSeed, out) => algM(seed, rebuildLayer[F, Seed, A](fSeed, out)), ) ) - /** Single-pass paired machine in `M` backing the fused `AnaFM.andThen(CataFM)` — the M mirror of + /** Single-pass paired machine in `M` backing the fused `AnaM.andThen(CataM)` — the M mirror of * [[fusedPairedFold]]: each node built once, folded immediately, no `M[S]` whole-structure * materialization. Mirrors the pure version exactly: `F[S]` and `F[A]` are built straight from * the out-array with two `F.map(fSeed)` passes and `var i = -1` counters, avoiding the @@ -388,23 +388,23 @@ object Schemes: * machine, not a trampoline). Requires `Project[F, S]` (to peel each layer) and `Traverse[F]` * (any lawful instance — the machine, not the user's `foldRight`, provides stack-safety). */ - def cataF[F[_], S, A]( + def cata[F[_], S, A]( alg: (S, F[A]) => A - )(using F: Traverse[F], P: Project[F, S]): CataF[F, S, A] = - new CataF[F, S, A](cataF[F, S, A, A](Decor.cata[F, A])(alg).get, alg) + )(using F: Traverse[F], P: Project[F, S]): Cata[F, S, A] = + new Cata[F, S, A](cata[F, S, A, A](Decor.cata[F, A])(alg).get, alg) /** Generalized (decorated) catamorphism — the gcata of the typed path, with the decoration * supplied as a [[DecorGather]] optic value. Interior nodes apply `gather ∘ galg` (the * decoration's `from` consuming `Step(layer, result)`); the **root applies `galg` alone** - * (droste's `gcata` shape). The named zoo members are instances: `cataF(alg)` routes here with - * [[Decor.cata]] (recognised by identity — the direct, decoration-free engine path), `histoF` + * (droste's `gcata` shape). The named zoo members are instances: `cata(alg)` routes here with + * [[Decor.cata]] (recognised by identity — the direct, decoration-free engine path), `histo` * with [[Decor.histo]]; user-written decorations (zygo, dyna, …) run the generic route, which * pays one decoration dispatch + `Step` per node. * - * (type-param order: `[F, S, W, A]` — compare [[anaF]] `[F, A, W, S]`, which mirrors these in + * (type-param order: `[F, S, W, A]` — compare [[ana]] `[F, A, W, S]`, which mirrors these in * input-before-output order: `A` is the input seed there, `S` the built output.) */ - def cataF[F[_], S, W, A]( + def cata[F[_], S, W, A]( decor: DecorGather[F, W, A] )(galg: (S, F[W]) => A)(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = if decor.asInstanceOf[AnyRef] eq Decor.cata[F, A] then @@ -431,7 +431,7 @@ object Schemes: * (droste's `Gather.para` must reconstruct the subterm it threw away). Stack-safe (the * [[foldLayered]] machine). */ - def paraF[F[_], S, A]( + def para[F[_], S, A]( alg: (S, F[(S, A)]) => A )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = Getter[S, A]( @@ -452,7 +452,7 @@ object Schemes: * * Space honesty: course-of-value recursion retains O(n) `Attr` cells by nature. */ - def histoF[F[_], S, A]( + def histo[F[_], S, A]( alg: (S, F[Attr[F, A]]) => A )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = val toAttr: S => Attr[F, A] = foldLayered[F, S, Attr[F, A]]( @@ -467,14 +467,14 @@ object Schemes: * => F[Seed]` yields one typed layer of child seeds; [[Embed]] assembles each layer into the * built `S`. Materializing — the built `S` is O(nodes). Stack-safe (the [[foldLayered]] machine). * Requires `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input before output) - * to match [[hyloF]] and the `PSVec` [[ana]]. + * to match [[hylo]] and the `PSVec` [[ana]]. */ - def anaF[F[_], Seed, S]( + def ana[F[_], Seed, S]( coalg: Seed => F[Seed] - )(using F: Traverse[F], E: Embed[F, S]): AnaF[F, Seed, S] = - new AnaF[F, Seed, S](anaF[F, Seed, Seed, S](Decor.ana[F, Seed])(coalg).reverseGet, coalg) + )(using F: Traverse[F], E: Embed[F, S]): Ana[F, Seed, S] = + new Ana[F, Seed, S](ana[F, Seed, Seed, S](Decor.ana[F, Seed])(coalg).reverseGet, coalg) - /** Single-pass paired machine backing the fused `AnaF.cross(CataF)`: each node is built once (the + /** Single-pass paired machine backing the fused `Ana.cross(Cata)`: each node is built once (the * algebra is node-supplied — construction is semantically required), folded immediately, and * released as the fold ascends. No full-tree retention, no second traversal. Leaf layers are * phantom-recast (valid because pattern-functor leaves have no recursive slots by definition). @@ -515,7 +515,7 @@ object Schemes: * recursed, never projected ([[foldLayeredOr]]). Contrast droste's scatter-apo, which re-walks * grafts through `project` (O(graft) per graft — the route [[Decor.apo]] documents). Stack-safe. */ - def apoF[F[_], A, S]( + def apo[F[_], A, S]( coalg: A => F[Either[S, A]] )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = val run = foldLayeredOr[F, Either[S, A], S]( @@ -536,7 +536,7 @@ object Schemes: * `DecorLawsSpec`). The gap to droste's futu (655k vs 459k B/op) is the stack-safe machine's * per-node child array — droste's zoo recursion is stack-unsafe. */ - def futuF[F[_], A, S]( + def futu[F[_], A, S]( coalg: A => F[Coattr[F, A]] )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = val expand: Coattr[F, A] => F[Coattr[F, A]] = @@ -552,16 +552,16 @@ object Schemes: * as a [[DecorScatter]] optic value. Each `W` slot is scattered (the decoration's `to`): * `Step(_, seed)` calls `gcoalg`, `Done(layer)` unrolls the prebuilt layer with **no coalgebra * call**. The root seed enters through the decoration's pointed unit (`from` on the Step arm — - * gana's `pure`). `anaF(coalg)` routes here with [[Decor.ana]] (identity-recognised direct - * path); `futuF` with [[Decor.futu]]; `Decor.apo` runs the generic distApo route — the O(1) - * graft belongs to the native `apoF` engine. + * gana's `pure`). `ana(coalg)` routes here with [[Decor.ana]] (identity-recognised direct path); + * `futu` with [[Decor.futu]]; `Decor.apo` runs the generic distApo route — the O(1) graft + * belongs to the native `apo` engine. * * For user-written [[DecorScatter]] values, `Done.fst` MUST carry `F[W]` at runtime — the engine * unrolls it directly as the next layer. * - * (type-param order: compare [[cataF]] `[F, S, W, A]` — the fold mirror swaps `Seed`/`A`.) + * (type-param order: compare [[cata]] `[F, S, W, A]` — the fold mirror swaps `Seed`/`A`.) */ - def anaF[F[_], A, W, S]( + def ana[F[_], A, W, S]( decor: DecorScatter[F, W, A] )(gcoalg: A => F[W])(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = if decor.asInstanceOf[AnyRef] eq Decor.ana[F, A] then @@ -585,10 +585,10 @@ object Schemes: * **no intermediate `S`** (so it needs neither `Project` nor `Embed`, only `Traverse[F]`). * `coalg` unfolds a seed into one typed layer; `alg` folds the layer's results to `A` (the seed * is supplied, paramorphism-flavored). Stack-safe (the [[foldLayered]] machine). Equal to - * `anaF(coalg).cross(cataF(alg))` for a *pure* algebra (the hylo law); for a node-reading para + * `ana(coalg).cross(cata(alg))` for a *pure* algebra (the hylo law); for a node-reading para * algebra the two agree only under the seed↔`embed(coalg(seed))` correspondence. */ - def hyloF[F[_], Seed, A]( + def hylo[F[_], Seed, A]( coalg: Seed => F[Seed], alg: (Seed, F[A]) => A, )(using F: Traverse[F]): Getter[Seed, A] = diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala index acd29eaf..e1e7acc5 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala @@ -9,7 +9,7 @@ import optics.Optic import schemes.samples.{Bin, BinF} /** Decoration laws — the per-value equations of the [[Decor]] vocabulary, plus the - * behaviour-identity of the re-derived `cataF`/`anaF` (the identity fast path must agree with the + * behaviour-identity of the re-derived `cata`/`ana` (the identity fast path must agree with the * generic decoration route, proven by running a *fresh* user-written id decoration through the * generic route and comparing). */ @@ -125,25 +125,25 @@ class DecorLawsSpec extends Specification: case s: Step[X, Int] => s.b case _: Done[X, Int] => throw new UnsupportedOperationException("unit on Step only") - "the generic decoration route agrees with the identity fast path on cataF" >> { - Schemes.cataF[BinF, Bin, Int, Int](freshIdGather)(sumAlg).get(tree) === - Schemes.cataF[BinF, Bin, Int](sumAlg).get(tree) + "the generic decoration route agrees with the identity fast path on cata" >> { + Schemes.cata[BinF, Bin, Int, Int](freshIdGather)(sumAlg).get(tree) === + Schemes.cata[BinF, Bin, Int](sumAlg).get(tree) } - "the generic decoration route agrees with the identity fast path on anaF" >> { + "the generic decoration route agrees with the identity fast path on ana" >> { def expand(n: Int): BinF[Int] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) - Schemes.anaF[BinF, Int, Int, Bin](freshIdScatter)(expand).reverseGet(5) === - Schemes.anaF[BinF, Int, Bin](expand).reverseGet(5) + Schemes.ana[BinF, Int, Int, Bin](freshIdScatter)(expand).reverseGet(5) === + Schemes.ana[BinF, Int, Bin](expand).reverseGet(5) } "histo through Decor.histo: heads-only course-of-value == cata" >> { val viaHisto = Schemes - .cataF[BinF, Bin, Attr[BinF, Int], Int](Decor.histo[BinF, Int]) { (s, layer) => + .cata[BinF, Bin, Attr[BinF, Int], Int](Decor.histo[BinF, Int]) { (s, layer) => sumAlg(s, BinF.traverse.map(layer)(_.head)) } .get(tree) - viaHisto === Schemes.cataF[BinF, Bin, Int](sumAlg).get(tree) + viaHisto === Schemes.cata[BinF, Bin, Int](sumAlg).get(tree) } "futu through Decor.futu: a two-layer-per-step coalgebra builds the right tree" >> { @@ -153,14 +153,14 @@ class DecorLawsSpec extends Specification: if n <= 1 then BinF.LeafF(1) else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) val built = Schemes - .anaF[BinF, Int, Coattr[BinF, Int], Bin](Decor.futu[BinF, Int])(coalg) + .ana[BinF, Int, Coattr[BinF, Int], Bin](Decor.futu[BinF, Int])(coalg) .reverseGet(3) built === Bin.Branch(Bin.Leaf(3), Bin.Branch(Bin.Leaf(2), Bin.Leaf(1))) } // ----- native routes == generic decoration routes (the perf-win pins) -------- - "native histoF == the generic route at Decor.histo" >> { + "native histo == the generic route at Decor.histo" >> { val alg: (Bin, BinF[Attr[BinF, Int]]) => Int = (s, layer) => sumAlg( s, @@ -172,21 +172,21 @@ class DecorLawsSpec extends Specification: } ), ) - Schemes.histoF[BinF, Bin, Int](alg).get(tree) === - Schemes.cataF[BinF, Bin, Attr[BinF, Int], Int](Decor.histo[BinF, Int])(alg).get(tree) + Schemes.histo[BinF, Bin, Int](alg).get(tree) === + Schemes.cata[BinF, Bin, Attr[BinF, Int], Int](Decor.histo[BinF, Int])(alg).get(tree) } - "native futuF == the generic route at Decor.futu" >> { + "native futu == the generic route at Decor.futu" >> { def coalg(n: Int): BinF[Coattr[BinF, Int]] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) - Schemes.futuF[BinF, Int, Bin](coalg).reverseGet(4) === - Schemes.anaF[BinF, Int, Coattr[BinF, Int], Bin](Decor.futu[BinF, Int])(coalg).reverseGet(4) + Schemes.futu[BinF, Int, Bin](coalg).reverseGet(4) === + Schemes.ana[BinF, Int, Coattr[BinF, Int], Bin](Decor.futu[BinF, Int])(coalg).reverseGet(4) } // ----- generic-route end-to-end pins -------------------------------------- - "native paraF == the generic route at Decor.para" >> { + "native para == the generic route at Decor.para" >> { // Algebra that uses BOTH the paired subterm and the result: // for a branch, sum child results and add 1 for each child subterm that is a Leaf. // tree = Branch(Branch(Leaf(1), Leaf(2)), Leaf(3)) @@ -200,20 +200,20 @@ class DecorLawsSpec extends Specification: case BinF.BranchF((ls, la), (rs, ra)) => la + ra + (if ls.isInstanceOf[Bin.Leaf] then 1 else 0) + (if rs.isInstanceOf[Bin.Leaf] then 1 else 0) - Schemes.paraF[BinF, Bin, Int](alg).get(tree) === - Schemes.cataF[BinF, Bin, (Bin, Int), Int](Decor.para[BinF, Bin, Int])(alg).get(tree) + Schemes.para[BinF, Bin, Int](alg).get(tree) === + Schemes.cata[BinF, Bin, (Bin, Int), Int](Decor.para[BinF, Bin, Int])(alg).get(tree) } - "native apoF == the generic route at Decor.apo (distApo)" >> { + "native apo == the generic route at Decor.apo (distApo)" >> { // Coalg with one graft: at n <= 0 emit a leaf; otherwise graft Bin.Leaf(n) as left child. - // The native apoF places the graft by reference; the generic route (distApo via project) + // The native apo places the graft by reference; the generic route (distApo via project) // rebuilds it — structural equality (==) holds, reference identity (eq) only for native. def coalg(n: Int): BinF[Either[Bin, Int]] = if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Left(Bin.Leaf(n)), Right(n - 1)) - val nativeResult = Schemes.apoF[BinF, Int, Bin](coalg).reverseGet(2) + val nativeResult = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(2) val genericResult = - Schemes.anaF[BinF, Int, Either[Bin, Int], Bin](Decor.apo[BinF, Bin, Int])(coalg).reverseGet(2) + Schemes.ana[BinF, Int, Either[Bin, Int], Bin](Decor.apo[BinF, Bin, Int])(coalg).reverseGet(2) // Both produce the same tree by value; use == not eq (generic route REBUILDS the graft). nativeResult === genericResult } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala index 69bb5363..08bfe807 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala @@ -6,8 +6,8 @@ import org.specs2.mutable.Specification import schemes.samples.BinF /** Unit checks for the decoration data ([[Attr]] / [[Coattr]]) — construction, projection, and - * structural equality over a real pattern functor. The zoo members that consume them (`histoF` / - * `futuF`) carry the behavioural coverage. + * structural equality over a real pattern functor. The zoo members that consume them (`histo` / + * `futu`) carry the behavioural coverage. */ class DecorSpec extends Specification: diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala index 7d848104..17127eee 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala @@ -6,13 +6,13 @@ import org.specs2.mutable.Specification import optics.Getter import schemes.samples.{Bin, BinF} -/** The fusion seam: `anaF(c).cross(cataF(a))`. +/** The fusion seam: `ana(c).cross(cata(a))`. * * - Resolution pin: the ascription `: Getter[Seed, A]` compiles only if the FUSED overload on - * the concrete `AnaF` wins (the generic trait `cross` returns a bare `Optic`, not a + * the concrete `Ana` wins (the generic trait `cross` returns a bare `Optic`, not a * `Getter`) — the matrix-spec-style proof the overload set resolves as designed. * - Fusion law: fused cross == the materializing composition (all algebras, extensional), and - * == `hyloF` for algebras that read the node only through the seed↔`embed(coalg(seed))` + * == `hylo` for algebras that read the node only through the seed↔`embed(coalg(seed))` * correspondence (here: a pure algebra typed at both nodes and seeds). */ class FusionSpec extends Specification: @@ -25,33 +25,33 @@ class FusionSpec extends Specification: case BinF.LeafF(n) => n case BinF.BranchF(l, r) => l + r - // Pure algebra, seed-typed for hyloF (node argument ignored on both sides). + // Pure algebra, seed-typed for hylo (node argument ignored on both sides). private val sumAlgSeed: (Int, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(n) => n case BinF.BranchF(l, r) => l + r - "anaF(c).cross(cataF(a)) resolves to the FUSED overload (ascription pin)" >> { - val fused: Getter[Int, Int] = Schemes.anaF[BinF, Int, Bin](expand).cross(Schemes.cataF(sumAlg)) + "ana(c).cross(cata(a)) resolves to the FUSED overload (ascription pin)" >> { + val fused: Getter[Int, Int] = Schemes.ana[BinF, Int, Bin](expand).cross(Schemes.cata(sumAlg)) fused.get(6) === 6 // six leaves of weight 1 } "fused cross == the materializing composition (node-supplied algebra)" >> { val seeds = List(1, 2, 3, 5, 8, 13) - val ana = Schemes.anaF[BinF, Int, Bin](expand) - val cata = Schemes.cataF(sumAlg) + val ana = Schemes.ana[BinF, Int, Bin](expand) + val cata = Schemes.cata(sumAlg) val fused = ana.cross(cata) seeds.map(fused.get) === seeds.map(s => cata.get(ana.reverseGet(s))) } - "fused cross == hyloF for a pure algebra (the hylo law under the correspondence)" >> { + "fused cross == hylo for a pure algebra (the hylo law under the correspondence)" >> { val seeds = List(1, 2, 3, 5, 8, 13) - val fused = Schemes.anaF[BinF, Int, Bin](expand).cross(Schemes.cataF(sumAlg)) - seeds.map(fused.get) === seeds.map(Schemes.hyloF[BinF, Int, Int](expand, sumAlgSeed).get) + val fused = Schemes.ana[BinF, Int, Bin](expand).cross(Schemes.cata(sumAlg)) + seeds.map(fused.get) === seeds.map(Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get) } // Depth bar: stack-safety needs >> the ~10k-frame JVM stack; 200k proves the machine - // (the 10^6 SPACE bar is carried by the anaF/apoF sweeps — this suite's fused machine + // (the 10^6 SPACE bar is carried by the ana/apo sweeps — this suite's fused machine // additionally retains the (S, A) pairs, and the suites share one test JVM). "fused cross is stack-safe on a 200k-deep spine (single pass)" >> { val Deep = 200_000 @@ -63,6 +63,6 @@ class FusionSpec extends Specification: fa match case BinF.LeafF(_) => 0 case BinF.BranchF(l, r) => 1 + math.max(l, r) - val fused = Schemes.anaF[BinF, Int, Bin](leafOrSpine).cross(Schemes.cataF(depthAlg)) + val fused = Schemes.ana[BinF, Int, Bin](leafOrSpine).cross(Schemes.cata(depthAlg)) (fused.get(Deep) == Deep) must beTrue } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala similarity index 81% rename from schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala rename to schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala index 52793707..05389609 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala @@ -14,13 +14,13 @@ import schemes.samples.{Bin, BinF, Rose, RoseF} * * - '''Project/Embed coherence''' — the hand-written `S`↔`F` correspondence is not * compiler-checked, so its two round-trip laws are property-tested here. - * - '''Hylo law (pure flavor)''' — `hyloF == anaF.cross(cataF)` holds *generically* only when - * the algebra ignores its node argument (a pure `F[A] => A` fold). Tested via `forAll`. - * - '''Hylo law (para flavor)''' — for a node-reading algebra, `hyloF` threads the *seed* while - * the materializing `cataF` threads the rebuilt `S`, so the two coincide only under the + * - '''Hylo law (pure flavor)''' — `hylo == ana.cross(cata)` holds *generically* only when the + * algebra ignores its node argument (a pure `F[A] => A` fold). Tested via `forAll`. + * - '''Hylo law (para flavor)''' — for a node-reading algebra, `hylo` threads the *seed* while + * the materializing `cata` threads the rebuilt `S`, so the two coincide only under the * seed↔`embed(coalg(seed))` correspondence. Verified at specific points, NOT via `forAll`. */ -class SchemesFLawsSpec extends Specification with ScalaCheck: +class SchemesLawsSpec extends Specification with ScalaCheck: // bounded-depth Bin generator (keeps trees small for the property runs) private def genBin(depth: Int): Gen[Bin] = @@ -95,13 +95,13 @@ class SchemesFLawsSpec extends Specification with ScalaCheck: case BinF.BranchF(l, r) => l + r } - "hyloF == anaF.cross(cataF) for a PURE algebra (the hylo law)" >> { + "hylo == ana.cross(cata) for a PURE algebra (the hylo law)" >> { forAll(Gen.choose(0, 12)) { (seed: Int) => - val fused = Schemes.hyloF[BinF, Int, Int](coalg, (_, fa) => pureSum(fa)).get(seed) + val fused = Schemes.hylo[BinF, Int, Int](coalg, (_, fa) => pureSum(fa)).get(seed) val materializing = Schemes - .anaF[BinF, Int, Bin](coalg) - .cross(Schemes.cataF[BinF, Bin, Int]((_, fa) => pureSum(fa))) + .ana[BinF, Int, Bin](coalg) + .cross(Schemes.cata[BinF, Bin, Int]((_, fa) => pureSum(fa))) .get(seed) fused == materializing } @@ -109,16 +109,16 @@ class SchemesFLawsSpec extends Specification with ScalaCheck: // ----- hylo law, para flavor (point tests, not forAll) ----- // - // A node-reading algebra: hyloF sees the Int seed at each layer; the materializing path sees the + // A node-reading algebra: hylo sees the Int seed at each layer; the materializing path sees the // rebuilt Bin. They are NOT equal in general — verified here only that fused matches itself and a // hand-computed value, documenting why forAll does not apply. - "para-flavored hyloF computes the expected value at specific seeds" >> { + "para-flavored hylo computes the expected value at specific seeds" >> { // alg reads neither node meaningfully here but is typed para; leaf-count over the spine val leafCount: (Int, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 1 case BinF.BranchF(l, r) => l + r - val h = Schemes.hyloF(coalg, leafCount) + val h = Schemes.hylo(coalg, leafCount) (h.get(0) == 1).and(h.get(3) == 4).and(h.get(7) == 8) } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala similarity index 74% rename from schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala rename to schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala index e1496e76..0fd326f9 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFMSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala @@ -7,11 +7,11 @@ import org.specs2.mutable.Specification import schemes.samples.{Bin, BinF} -/** The M-generic path (`cataFM` / `anaFM` / `hyloFM`, the tailRecM-lifted machine): +/** The M-generic path (`cataM` / `anaM` / `hyloM`, the tailRecM-lifted machine): * * - Fast-path agreement laws — `M = Id` on the lifted machine == the pure citizens on the hybrid * machine (a real cross-architecture pin: there is NO Id special-case). - * - The fused `AnaFM.andThen(CataFM)` == the run-then-run composition (extensional), resolved to + * - The fused `AnaM.andThen(CataM)` == the run-then-run composition (extensional), resolved to * the concrete member (ascription pin). * - Stack-safety on `Eval` (200k spine; safety rides on M's `tailRecM` — tested, not asserted). * - The linear-M boundary: `List` (a branching M) is documented UNSUPPORTED — the machine's @@ -20,7 +20,7 @@ import schemes.samples.{Bin, BinF} * - The arbo-shaped acceptance example: children fetched effectfully (a counted `GetSellOptions` * analogue in `State`), built and folded in ONE fused pass. */ -class SchemesFMSpec extends Specification: +class SchemesMSpec extends Specification: // Deep (10^6 / 200k) examples: run one-at-a-time to bound peak heap (shared test JVM). sequential @@ -43,27 +43,27 @@ class SchemesFMSpec extends Specification: // ----- fast-path agreement (M = Id vs the pure hybrid machine) ------------- - "cataFM[Id].run == cataF.get (cross-architecture agreement)" >> { - Schemes.cataFM[Id, BinF, Bin, Int]((s, fa) => sumAlg(s, fa)).run(tree) === - Schemes.cataF(sumAlg).get(tree) + "cataM[Id].run == cata.get (cross-architecture agreement)" >> { + Schemes.cataM[Id, BinF, Bin, Int]((s, fa) => sumAlg(s, fa)).run(tree) === + Schemes.cata(sumAlg).get(tree) } - "anaFM[Id].run == anaF.reverseGet" >> { - Schemes.anaFM[Id, BinF, Int, Bin](n => expand(n)).run(6) === - Schemes.anaF[BinF, Int, Bin](expand).reverseGet(6) + "anaM[Id].run == ana.reverseGet" >> { + Schemes.anaM[Id, BinF, Int, Bin](n => expand(n)).run(6) === + Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) } - "hyloFM[Id].run == hyloF.get" >> { - Schemes.hyloFM[Id, BinF, Int, Int](n => expand(n), (s, fa) => sumAlgSeed(s, fa)).run(13) === - Schemes.hyloF[BinF, Int, Int](expand, sumAlgSeed).get(13) + "hyloM[Id].run == hylo.get" >> { + Schemes.hyloM[Id, BinF, Int, Int](n => expand(n), (s, fa) => sumAlgSeed(s, fa)).run(13) === + Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get(13) } // ----- fusion --------------------------------------------------------------- - "AnaFM.andThen(CataFM) resolves to the fused member (ascription pin) and == run∘run" >> { - val anaM = Schemes.anaFM[Id, BinF, Int, Bin](n => expand(n)) - val cataM = Schemes.cataFM[Id, BinF, Bin, Int]((s, fa) => sumAlg(s, fa)) - val fused: FoldFM[Id, Int, Int] = anaM.andThen(cataM) // generic andThen returns a bare Optic + "AnaM.andThen(CataM) resolves to the fused member (ascription pin) and == run∘run" >> { + val anaM = Schemes.anaM[Id, BinF, Int, Bin](n => expand(n)) + val cataM = Schemes.cataM[Id, BinF, Bin, Int]((s, fa) => sumAlg(s, fa)) + val fused: FoldM[Id, Int, Int] = anaM.andThen(cataM) // generic andThen returns a bare Optic List(1, 2, 3, 5, 8, 13).map(fused.run) === List(1, 2, 3, 5, 8, 13).map(seed => cataM.run(anaM.run(seed))) } @@ -73,7 +73,7 @@ class SchemesFMSpec extends Specification: // Depth bar: stack-safety needs >> the ~10k-frame JVM stack; 200k proves the // tailRecM-driven machine (the 10^6 SPACE bar lives with the pure-machine sweeps — // Eval's tailRecM adds per-event Either+Eval nodes, and the suites share one JVM). - "hyloFM[Eval] is stack-safe on a 200k-deep spine (tailRecM-driven, no intermediate Bin)" >> { + "hyloM[Eval] is stack-safe on a 200k-deep spine (tailRecM-driven, no intermediate Bin)" >> { val Deep = 200_000 def spine(n: Int): BinF[Int] = if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) @@ -83,7 +83,7 @@ class SchemesFMSpec extends Specification: case BinF.LeafF(_) => 0 case BinF.BranchF(l, r) => 1 + math.max(l, r) val run = Schemes - .hyloFM[Eval, BinF, Int, Int]( + .hyloM[Eval, BinF, Int, Int]( n => Eval.now(leafOrSpine(n)), (s, fa) => Eval.now(depthAlg(s, fa)), ) @@ -101,38 +101,38 @@ class SchemesFMSpec extends Specification: def coalgM(n: Int): List[BinF[Int]] = if n == 9 then List(BinF.LeafF(1), BinF.LeafF(2)) else List(BinF.LeafF(n)) val results = Schemes - .hyloFM[List, BinF, Int, Int](coalgM, (_, fa) => List(sumAlgSeed(0, fa))) + .hyloM[List, BinF, Int, Int](coalgM, (_, fa) => List(sumAlgSeed(0, fa))) .run(9) (results != List(1, 2)) must beTrue } // ----- propagation of short-circuiting effects -------------------------------- - "cataFM[Option] propagates a mid-fold None" >> { + "cataM[Option] propagates a mid-fold None" >> { // Algebra returns None for the inner branch (the Branch(Leaf(1), Leaf(2)) node) only. val algM: (Bin, BinF[Int]) => Option[Int] = (s, fa) => s match case Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)) => None // force failure at this node case _ => Some(sumAlg(s, fa)) - Schemes.cataFM[Option, BinF, Bin, Int](algM).run(tree) === None + Schemes.cataM[Option, BinF, Bin, Int](algM).run(tree) === None } - "anaFM[Option] propagates a mid-unfold None" >> { + "anaM[Option] propagates a mid-unfold None" >> { // Coalg returns None at seed 3 — forces failure mid-build. val coalgM: Int => Option[BinF[Int]] = n => if n == 3 then None else Some(expand(n)) - Schemes.anaFM[Option, BinF, Int, Bin](coalgM).run(6) === None + Schemes.anaM[Option, BinF, Int, Bin](coalgM).run(6) === None } // ----- re-forcing the same Eval result is safe (Fix 1 regression guard) ------ "re-forcing the same Eval result is safe (fresh state per force)" >> { val m = Schemes - .hyloFM[Eval, BinF, Int, Int]( + .hyloM[Eval, BinF, Int, Int]( n => Eval.now(expand(n)), (s, fa) => Eval.now(sumAlgSeed(s, fa)), ) .run(6) - val expected = Schemes.hyloF[BinF, Int, Int](expand, sumAlgSeed).get(6) + val expected = Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get(6) (m.value === expected).and(m.value === expected) } @@ -145,7 +145,7 @@ class SchemesFMSpec extends Specification: Eval.later(throw new RuntimeException("deliberate first-force failure")) else Eval.now(sumAlgSeed(seed, fa)) val m = Schemes - .hyloFM[Eval, BinF, Int, Int](n => Eval.now(expand(n)), algM) + .hyloM[Eval, BinF, Int, Int](n => Eval.now(expand(n)), algM) .run(6) // First force: throws val firstThrew = @@ -153,7 +153,7 @@ class SchemesFMSpec extends Specification: catch case _: RuntimeException => true // Second force: flag already set, should succeed with correct answer - val expected = Schemes.hyloF[BinF, Int, Int](expand, sumAlgSeed).get(6) + val expected = Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get(6) firstThrew.and(m.value === expected) } @@ -165,9 +165,9 @@ class SchemesFMSpec extends Specification: type Svc[T] = State[Int, T] def fetchOptions(n: Int): Svc[BinF[Int]] = State(calls => (calls + 1, expand(n))) - val build = Schemes.anaFM[Svc, BinF, Int, Bin](fetchOptions) - val best = Schemes.cataFM[Svc, BinF, Bin, Int]((s, fa) => State.pure(sumAlg(s, fa))) - val selection: FoldFM[Svc, Int, Int] = build.andThen(best) + val build = Schemes.anaM[Svc, BinF, Int, Bin](fetchOptions) + val best = Schemes.cataM[Svc, BinF, Bin, Int]((s, fa) => State.pure(sumAlg(s, fa))) + val selection: FoldM[Svc, Int, Int] = build.andThen(best) val (calls, result) = selection.run(6).run(0).value // expand(6) yields 11 nodes (6 leaves of weight 1) — one fetch per node, one pass. diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala similarity index 70% rename from schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala rename to schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala index e1728386..32444220 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesFSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -13,15 +13,15 @@ import optics.Optic.* // get, andThen, cross, foldMap import schemes.samples.{Bin, BinF, Rose, RoseF} -/** Behaviour spec for the typed pattern-functor schemes (`cataF` / `anaF` / `hyloF`) and `fLayer`. - * Companion law/coherence checks live in `SchemesFLawsSpec`. +/** Behaviour spec for the typed pattern-functor schemes (`cata` / `ana` / `hylo`) and `fLayer`. + * Companion law/coherence checks live in `SchemesLawsSpec`. * * Type-safety note (R3): every `gather`/`alg`/`coalg` below pattern-matches `BinF`'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`. */ -class SchemesFSpec extends Specification: +class SchemesSpec extends Specification: // A small mixed tree: Branch(Leaf 1, Branch(Leaf 2, Leaf 3)) — leaf sum 6, 3 leaves, depth 2. private val tree: Bin = @@ -33,14 +33,14 @@ class SchemesFSpec extends Specification: case BinF.LeafF(n) => n case BinF.BranchF(l, r) => l + r - // ----- cataF (typed fold) ----- + // ----- cata (typed fold) ----- - "cataF folds a Bin to a value through F's named constructors" >> { - val sumG: CataF[BinF, Bin, Int] = Schemes.cataF(sumLeaves) + "cata folds a Bin to a value through F's named constructors" >> { + val sumG: Cata[BinF, Bin, Int] = Schemes.cata(sumLeaves) (sumG.get(tree) == 6) must beTrue } - "cataF is paramorphism-flavored: gather can read the original node S" >> { + "cata is paramorphism-flavored: gather can read the original node S" >> { // count Branch nodes by reading the node argument, not the folded children val branchCount: (Bin, BinF[Int]) => Int = (node, fa) => val here = node match @@ -50,12 +50,12 @@ class SchemesFSpec extends Specification: case BinF.LeafF(_) => 0 case BinF.BranchF(l, r) => l + r here + below - (Schemes.cataF(branchCount).get(tree) == 2) must beTrue + (Schemes.cata(branchCount).get(tree) == 2) must beTrue } - "cataF-as-Getter composes onto an outer Getter via andThen" >> { + "cata-as-Getter composes onto an outer Getter via andThen" >> { val composed: Getter[(String, Bin), Int] = - Getter[(String, Bin), Bin](_._2).andThen(Schemes.cataF(sumLeaves)) + Getter[(String, Bin), Bin](_._2).andThen(Schemes.cata(sumLeaves)) (composed.get(("x", tree)) == 6) must beTrue } @@ -69,29 +69,29 @@ class SchemesFSpec extends Specification: val doc = Doc(1, Inner("x", tree)) // tree = leaf sum 6 // read: focus the recursive field with the composed lens, fold it with the scheme. - // (Schemes are read-only Getters, so we either read at the leaf — `cataF.get(lens.get(doc))` — + // (Schemes are read-only Getters, so we either read at the leaf — `cata.get(lens.get(doc))` — // or wrap the lens read in a Getter to build a reusable composed Getter[Doc, Int].) val docLeafSum: Getter[Doc, Int] = - Getter[Doc, Bin](deepTree.get).andThen(Schemes.cataF(sumLeaves)) + Getter[Doc, Bin](deepTree.get).andThen(Schemes.cata(sumLeaves)) val pruned = deepTree.replace(Bin.Leaf(0))(doc) // write through the same composed lens - (Schemes.cataF(sumLeaves).get(deepTree.get(doc)) == 6) + (Schemes.cata(sumLeaves).get(deepTree.get(doc)) == 6) .and(docLeafSum.get(doc) == 6) .and(deepTree.get(pruned) == Bin.Leaf(0)) .and(pruned.inner.label == "x") // the rest of the record is untouched } - // ----- anaF (typed build) ----- + // ----- ana (typed build) ----- - "anaF builds a Bin from a seed, then cataF reads it back" >> { + "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.anaF[BinF, Int, Bin](spine).reverseGet(3) + val built: Bin = Schemes.ana[BinF, Int, Bin](spine).reverseGet(3) // seed 3 -> Branch(Branch(Branch(Leaf 1, Leaf 1), Leaf 1), Leaf 1): 4 leaves of 1, depth 3 - (Schemes.cataF(sumLeaves).get(built) == 4).and( + (Schemes.cata(sumLeaves).get(built) == 4).and( Schemes - .cataF[BinF, Bin, Int]((_, fa) => + .cata[BinF, Bin, Int]((_, fa) => fa match case BinF.LeafF(_) => 0 case BinF.BranchF(l, _) => 1 + l @@ -100,41 +100,41 @@ class SchemesFSpec extends Specification: ) } - "anaF.cross(cataF) is the materializing hylo (builds the Bin, then folds)" >> { + "ana.cross(cata) is the materializing hylo (builds the Bin, then folds)" >> { val spine: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(n - 1, -1) - val refold = Schemes.anaF[BinF, Int, Bin](spine).cross(Schemes.cataF(sumLeaves)) + val refold = Schemes.ana[BinF, Int, Bin](spine).cross(Schemes.cata(sumLeaves)) (refold.get(3) == 4) must beTrue } - // ----- hyloF (fused refold, no intermediate Bin) ----- + // ----- hylo (fused refold, no intermediate Bin) ----- - "hyloF fuses unfold+fold with no intermediate Bin built" >> { + "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) val leafCount: (Int, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 1 case BinF.BranchF(l, r) => l + r // seed 3 -> a right spine of 4 leaves - (Schemes.hyloF(coalg, leafCount).get(3) == 4) must beTrue + (Schemes.hylo(coalg, leafCount).get(3) == 4) must beTrue } - "hyloF 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 leafCount: (Int, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 1 case BinF.BranchF(l, r) => l + r val toStr: Getter[Int, String] = - Schemes.hyloF(coalg, leafCount).andThen(Getter[Int, String](_.toString)) + Schemes.hylo(coalg, leafCount).andThen(Getter[Int, String](_.toString)) (toStr.get(3) == "4") must beTrue } // ----- edge cases ----- - "cataF / hyloF handle a single leaf (no recursive positions)" >> { - (Schemes.cataF(sumLeaves).get(Bin.Leaf(7)) == 7).and( + "cata / hylo handle a single leaf (no recursive positions)" >> { + (Schemes.cata(sumLeaves).get(Bin.Leaf(7)) == 7).and( Schemes - .hyloF[BinF, Int, Int]( + .hylo[BinF, Int, Int]( n => BinF.LeafF(n), (_, fa) => fa match @@ -145,8 +145,8 @@ class SchemesFSpec extends Specification: ) } - "cataF handles a one-level Branch(Leaf, Leaf)" >> { - (Schemes.cataF(sumLeaves).get(Bin.Branch(Bin.Leaf(4), Bin.Leaf(5))) == 9) must beTrue + "cata handles a one-level Branch(Leaf, Leaf)" >> { + (Schemes.cata(sumLeaves).get(Bin.Branch(Bin.Leaf(4), Bin.Leaf(5))) == 9) must beTrue } // ----- fLayer: the single-layer Forget[F] optic (R1) ----- @@ -166,7 +166,7 @@ class SchemesFSpec extends Specification: // ----- stack-safety (R2): 10^6 deep ----- // - // cataF and hyloF descend a 10^6-deep spine; anaF additionally materializes an O(n) Bin. The + // cata and hylo descend a 10^6-deep spine; ana additionally materializes an O(n) Bin. The // foldLayered machine (the same < 512-on-stack / heap-ArrayDeque hybrid as the PSVec schemes) // moves the deep recursion off the JVM call stack onto the heap, so these complete without // StackOverflowError where a naive recursion would overflow — in O(depth) space, no Eval chain, @@ -174,7 +174,7 @@ class SchemesFSpec extends Specification: private val Deep = 1_000_000 - "cataF is stack/space-safe folding a 10^6-deep Bin spine" >> { + "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 @@ -184,31 +184,31 @@ class SchemesFSpec extends Specification: fa match case BinF.LeafF(_) => 0 case BinF.BranchF(l, r) => 1 + math.max(l, r) - (Schemes.cataF(depth).get(b) == Deep) must beTrue + (Schemes.cata(depth).get(b) == Deep) must beTrue } - "hyloF is stack/space-safe at depth 10^6 (no intermediate Bin)" >> { + "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) val depth: (Int, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 0 case BinF.BranchF(l, r) => 1 + math.max(l, r) - (Schemes.hyloF(coalg, depth).get(Deep) == Deep) must beTrue + (Schemes.hylo(coalg, depth).get(Deep) == Deep) must beTrue } - "anaF is stack/space-safe building a 10^6-deep Bin (the OOM frontier)" >> { + "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.anaF[BinF, Int, Bin](coalg).reverseGet(Deep) + val deep: Bin = Schemes.ana[BinF, Int, Bin](coalg).reverseGet(Deep) val depth: (Bin, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 0 case BinF.BranchF(l, r) => 1 + math.max(l, r) - (Schemes.cataF(depth).get(deep) == Deep) must beTrue + (Schemes.cata(depth).get(deep) == Deep) must beTrue } // ----- wide-and-deep: exercise the Traverse[F] sequencing path a binary spine misses ----- - "cataF/hyloF stay safe on a wide-AND-deep RoseF (high fanout + deep)" >> { + "cata/hylo stay safe on a wide-AND-deep RoseF (high fanout + deep)" >> { val DeepRose = 100_000 val Width = 8 // each level: one deep child (d-1) + Width leaf children (-1 -> empty RoseF) @@ -218,19 +218,19 @@ class SchemesFSpec extends Specification: val countNodes: (Int, RoseF[Int]) => Int = (_, fr) => 1 + fr.kids.sum // nodes = (DeepRose+1) spine nodes + DeepRose*Width leaves val expected = (DeepRose + 1) + DeepRose * Width - (Schemes.hyloF(coalg, countNodes).get(DeepRose) == expected) must beTrue + (Schemes.hylo(coalg, countNodes).get(DeepRose) == expected) must beTrue } - // anaF + cataF over the N-ary RoseF (the hyloF case above never builds/folds a real Rose, so + // ana + cata over the N-ary RoseF (the hylo case above never builds/folds a real Rose, so // this is the only test exercising the Traverse[RoseF]+Eval sequencing for those two schemes). - "anaF builds and cataF folds a wide-AND-deep Rose (N-ary Project/Embed)" >> { + "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: (Rose, RoseF[Int]) => Int = (_, fr) => 1 + fr.kids.sum - val built: Rose = Schemes.anaF[RoseF, Int, Rose](coalg).reverseGet(DeepRose) + val built: Rose = Schemes.ana[RoseF, Int, Rose](coalg).reverseGet(DeepRose) val expected = (DeepRose + 1) + DeepRose * Width - (Schemes.cataF(countNodes).get(built) == expected) must beTrue + (Schemes.cata(countNodes).get(built) == expected) must beTrue } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala index ef50ff10..3473e744 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala @@ -5,10 +5,10 @@ import org.specs2.mutable.Specification import schemes.samples.{Bin, BinF} -/** Behaviour + law spec for the named zoo (`paraF` / `apoF` / `histoF` / `futuF`): +/** Behaviour + law spec for the named zoo (`para` / `apo` / `histo` / `futu`): * * - Degeneration laws — each member collapses to its plain dual when its decoration is unused. - * - The graft law — `apoF`'s `Left` subtree lands in the result **by reference** (`eq`): the + * - The graft law — `apo`'s `Left` subtree lands in the result **by reference** (`eq`): the * law-shaped form of the O(1)-graft claim, immune to bench-box noise. * - Stack-safety to 10⁶ per member (tested, not asserted). */ @@ -27,28 +27,28 @@ class SchemesZooSpec extends Specification: // ----- degeneration laws --------------------------------------------------- - "paraF ignoring subterms == cataF" >> { + "para ignoring subterms == cata" >> { val viaPara = Schemes - .paraF[BinF, Bin, Int] { (s, layer) => + .para[BinF, Bin, Int] { (s, layer) => sumAlg(s, BinF.traverse.map(layer)(_._2)) // drop the paired subterms } .get(tree) - viaPara === Schemes.cataF(sumAlg).get(tree) + viaPara === Schemes.cata(sumAlg).get(tree) } - "paraF sees the original subterm at every child slot" >> { - // Algebra returns (sum, ok): sum is the cataF sum of the subtree; ok checks the PAIRED + "para sees the original subterm at every child slot" >> { + // Algebra returns (sum, ok): sum is the cata sum of the subtree; ok checks the PAIRED // subterm re-folds to the PAIRED result — a non-tautological cross-check. val coherent = Schemes - .paraF[BinF, Bin, (Int, Boolean)] { (_, layer) => + .para[BinF, Bin, (Int, Boolean)] { (_, layer) => layer match case BinF.LeafF(n) => (n, true) case BinF.BranchF((ls, (lSum, lOk)), (rs, (rSum, rOk))) => ( lSum + rSum, lOk && rOk && - Schemes.cataF(sumAlg).get(ls) == lSum && - Schemes.cataF(sumAlg).get(rs) == rSum, + Schemes.cata(sumAlg).get(ls) == lSum && + Schemes.cata(sumAlg).get(rs) == rSum, ) } .get(tree) @@ -56,50 +56,50 @@ class SchemesZooSpec extends Specification: coherent === (10, true) } - "never-grafting apoF == anaF" >> { + "never-grafting apo == ana" >> { def expand(n: Int): BinF[Int] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) val viaApo = Schemes - .apoF[BinF, Int, Bin](n => BinF.traverse.map(expand(n))(Right(_))) + .apo[BinF, Int, Bin](n => BinF.traverse.map(expand(n))(Right(_))) .reverseGet(6) - viaApo === Schemes.anaF[BinF, Int, Bin](expand).reverseGet(6) + viaApo === Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) } - "heads-only histoF == cataF" >> { + "heads-only histo == cata" >> { val viaHisto = Schemes - .histoF[BinF, Bin, Int] { (s, layer) => + .histo[BinF, Bin, Int] { (s, layer) => sumAlg(s, BinF.traverse.map(layer)(_.head)) } .get(tree) - viaHisto === Schemes.cataF(sumAlg).get(tree) + viaHisto === Schemes.cata(sumAlg).get(tree) } - "single-layer futuF == anaF" >> { + "single-layer futu == ana" >> { def expand(n: Int): BinF[Int] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) val viaFutu = Schemes - .futuF[BinF, Int, Bin](n => BinF.traverse.map(expand(n))(Coattr.Pure(_))) + .futu[BinF, Int, Bin](n => BinF.traverse.map(expand(n))(Coattr.Pure(_))) .reverseGet(6) - viaFutu === Schemes.anaF[BinF, Int, Bin](expand).reverseGet(6) + viaFutu === Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) } // ----- the graft law (O(1), by reference) ---------------------------------- - "apoF grafts a finished subtree BY REFERENCE (eq), never rebuilt" >> { + "apo grafts a finished subtree BY REFERENCE (eq), never rebuilt" >> { val grafted: Bin = Bin.Branch(Bin.Leaf(98), Bin.Leaf(99)) // Unfold downward; at seed 1 graft the finished subtree as the left child. def coalg(n: Int): BinF[Either[Bin, Int]] = if n <= 0 then BinF.LeafF(7) else if n == 1 then BinF.BranchF(Left(grafted), Right(0)) else BinF.BranchF(Right(n - 1), Right(n - 1)) - val built = Schemes.apoF[BinF, Int, Bin](coalg).reverseGet(1) + 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 } - "apoF grafts by reference on the HEAP path too (depth > OnStackLimit=512)" >> { + "apo grafts by reference on the HEAP path too (depth > OnStackLimit=512)" >> { val grafted: Bin = Bin.Branch(Bin.Leaf(77), Bin.Leaf(88)) // Descend a spine to depth 600 (past the on-stack limit of 512), then graft once. val GraftDepth = 600 @@ -107,7 +107,7 @@ class SchemesZooSpec extends Specification: if n <= 0 then BinF.LeafF(0) else if n == 1 then BinF.BranchF(Left(grafted), Right(0)) else BinF.BranchF(Right(n - 1), Left(Bin.Leaf(0))) - val built = Schemes.apoF[BinF, Int, Bin](coalg).reverseGet(GraftDepth) + val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(GraftDepth) // Navigate left spine to find the graft slot (iterative — safe at any depth) var cursor: Bin = built var steps = GraftDepth - 1 @@ -122,11 +122,11 @@ class SchemesZooSpec extends Specification: (graftSlot.asInstanceOf[AnyRef] eq grafted.asInstanceOf[AnyRef]) === true } - "histoF uses real history: leaf-depth-weighted sum needs grandchildren" >> { + "histo uses real history: leaf-depth-weighted sum needs grandchildren" >> { // An algebra unreachable by plain cata in one pass: each branch adds its // grandchildren's results twice (course-of-value: reads two levels down). val cov = Schemes - .histoF[BinF, Bin, Int] { (_, layer) => + .histo[BinF, Bin, Int] { (_, layer) => layer match case BinF.LeafF(n) => n case BinF.BranchF(l, r) => @@ -153,43 +153,43 @@ class SchemesZooSpec extends Specification: i += 1 b - "paraF is stack/space-safe folding a 10^6-deep Bin spine" >> { + "para is stack/space-safe folding a 10^6-deep Bin spine" >> { val depth: (Bin, BinF[(Bin, Int)]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 0 case BinF.BranchF((_, l), (_, r)) => 1 + math.max(l, r) - (Schemes.paraF(depth).get(deepSpine()) == Deep) must beTrue + (Schemes.para(depth).get(deepSpine()) == Deep) must beTrue } - "apoF is stack/space-safe building a 10^6-deep Bin" >> { + "apo is stack/space-safe building a 10^6-deep Bin" >> { def coalg(n: Int): BinF[Either[Bin, Int]] = if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Right(n - 1), Left(Bin.Leaf(0))) - val built = Schemes.apoF[BinF, Int, Bin](coalg).reverseGet(Deep) + val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(Deep) val depth: (Bin, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 0 case BinF.BranchF(l, r) => 1 + math.max(l, r) - (Schemes.cataF(depth).get(built) == Deep) must beTrue + (Schemes.cata(depth).get(built) == Deep) must beTrue } - "histoF is stack/space-safe folding a 10^6-deep Bin spine (O(n) Attr cells)" >> { + "histo is stack/space-safe folding a 10^6-deep Bin spine (O(n) Attr cells)" >> { val depth: (Bin, BinF[Attr[BinF, Int]]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 0 case BinF.BranchF(l, r) => 1 + math.max(l.head, r.head) - (Schemes.histoF(depth).get(deepSpine()) == Deep) must beTrue + (Schemes.histo(depth).get(deepSpine()) == Deep) must beTrue } - "futuF is stack/space-safe building a 10^6-deep Bin (deep Coattr chains included)" >> { + "futu is stack/space-safe building a 10^6-deep Bin (deep Coattr chains included)" >> { // Every other step emits a prebuilt two-layer segment (Roll over Roll). def coalg(n: Int): BinF[Coattr[BinF, Int]] = if n <= 0 then BinF.LeafF(0) else if n % 2 == 0 then BinF.BranchF(Coattr.Roll(BinF.LeafF(0)), Coattr.Pure(n - 1)) else BinF.BranchF(Coattr.Pure(n - 1), Coattr.Roll(BinF.LeafF(0))) - val built = Schemes.futuF[BinF, Int, Bin](coalg).reverseGet(Deep) + val built = Schemes.futu[BinF, Int, Bin](coalg).reverseGet(Deep) val size: (Bin, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 1 case BinF.BranchF(l, r) => 1 + l + r - (Schemes.cataF(size).get(built) > Deep) must beTrue + (Schemes.cata(size).get(built) > Deep) must beTrue } 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 index ead40916..4283be48 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/samples/PatternFunctors.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/samples/PatternFunctors.scala @@ -4,9 +4,8 @@ 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.cataF]] / `anaF` / `hyloF`). Top-level — NOT nested - * in a spec class — to mirror the other samples and stay clear of the generics outer-accessor - * rule. + * 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 diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index 563eba4e..d28797c9 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -436,7 +436,7 @@ macro — but that emits a *function*, not an `Optic`, which would break the ## Recursion schemes — the typed path vs droste and hand-written -`SchemesBench` measures the typed recursion schemes (`cataF` / `anaF` / `hyloF` and the +`SchemesBench` measures the typed recursion schemes (`cata` / `ana` / `hylo` and the zoo — the `foldLayered` `ArrayDeque` machine, 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** @@ -453,9 +453,9 @@ runner): | `drosteCata` | 164 824 | 1× | | `drosteHylo` | 328 641 | 1× | | `drosteAna` | 327 632 | 1× | -| `eoCataF` | 361 385 | 2.2× | +| `eoCata` | 361 385 | 2.2× | | `eoHyloF` | 361 385 | 1.1× | -| `eoAnaF` | 524 193 | 1.6× | +| `eoAna` | 524 193 | 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 @@ -465,7 +465,7 @@ 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 (`paraF` / `apoF` / `histoF` / `futuF`) against +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 generic decoration route, the monadic machine at `cats.Id`, and fused-vs-materialised `cross`. As above, B/op is the trustworthy column; ns/op is directional. @@ -482,7 +482,7 @@ As above, B/op is the trustworthy column; ns/op is directional. | `drosteHisto` | 103 420 | 448 705 | 1× | | `eoFutu` | 188 749 | 655 249 | 1.43× | | `drosteFutu` | 93 458 | 458 689 | 1× | -| `eoCataF` | 172 428 | 361 385 | 2.19× | +| `eoCata` | 172 428 | 361 385 | 2.19× | | `eoCataGenericRoute` | 162 279 | 362 313 | 2.20× | | `drosteCata` | 56 542 | 164 824 | 1× | | `eoHyloF` | 180 767 | 361 385 | — | @@ -493,7 +493,7 @@ As above, B/op is the trustworthy column; ns/op is directional. Six results: - **`para` / `apo` halve droste's allocation.** eo decorates on the same array machine as - `cataF`/`anaF`, pairing subterms off the already-walked nodes; droste's zoo re-embeds each + `cata`/`ana`, pairing subterms off the already-walked nodes; droste's zoo re-embeds each subterm (para) and re-allocates the `Either` spine (apo), landing at ~2× eo's B/op (1 114 890 vs 557 945; 1 146 674 vs 655 249). The ns column agrees directionally (~1.5× in eo's favour on both). @@ -506,17 +506,17 @@ Six results: - **The generic decoration route costs nothing.** A user-written identity gather — which skips the driver's identity fast path — lands at 362 313 B/op vs the fast path's 361 385: escape analysis elides the per-node decoration wrapper, so writing your own `Decor` route is - alloc-free over `cataF`. + alloc-free over `cata`. - **`histo` / `futu` trail droste by ~1.2–1.4× 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 - 929 472 B/op vs 361 385 for `hyloF` (~2.6×) — that delta is the `tailRecM` step-event + 929 472 B/op vs 361 385 for `hylo` (~2.6×) — that delta is the `tailRecM` step-event wrapping, the price of arbitrary-monad algebras. This run includes the M-machine optimisation: the previous run (27384569800) had `eoHyloM` at 1 606 586 B/op, a **−42%** improvement. -- **Fused `cross` beats materialising on both axes.** Composing `anaF` into `cataF` via +- **Fused `cross` beats materialising on both axes.** Composing `ana` into `cata` via `cross` fuses into one pass — 239 393 ns / 820 066 B/op vs 375 824 ns / 885 579 B/op for build-the-tree-then-fold (~1.6× faster, no intermediate tree). The fused path also dropped **−22%** from the previous run's 1 049 417 B/op with the same optimisation commit. diff --git a/site/docs/schemes.md b/site/docs/schemes.md index 945bc46e..bfaf2e26 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -7,11 +7,11 @@ constructors** (compile-time arity safety, no positional indexing): | Scheme | Optic | Direction | |--------|-------|-----------| -| `cataF` | `CataF` (Getter-shaped) | fold an existing `S` to an `A` | -| `anaF` | `AnaF` (Review-shaped) | build an `S` from a seed | -| `hyloF` | `Getter[Seed, A]` (**fused** — no intermediate `S`) | unfold-and-fold in one pass | -| `paraF` / `apoF` / `histoF` / `futuF` | the zoo (below) | decorated folds / unfolds | -| `cataFM` / `anaFM` / `hyloFM` | `Forget[M]`-carried | effectful steps in a `Monad[M]` | +| `cata` | `Cata` (Getter-shaped) | fold an existing `S` to an `A` | +| `ana` | `Ana` (Review-shaped) | 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` | `Forget[M]`-carried | effectful steps in a `Monad[M]` | 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, @@ -26,7 +26,7 @@ import dev.constructive.eo.schemes.Schemes import dev.constructive.eo.optics.Getter ``` -## The pattern-functor setup — `cataF` / `anaF` / `hyloF` +## The pattern-functor setup — `cata` / `ana` / `hylo` 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 @@ -37,7 +37,7 @@ You write three things: the functor `F`, its `cats.Traverse`, and a `Basis` (`Pr ```scala mdoc:silent import cats.{Applicative, Eval, Traverse} -import dev.constructive.eo.schemes.{Basis, CataF} // `Schemes`, `Getter`, `get` already imported above +import dev.constructive.eo.schemes.{Basis, Cata} // `Schemes`, `Getter`, `get` already imported above // A binary tree… enum Bin: @@ -69,12 +69,12 @@ given Basis[BinF, Bin] = Basis( val binTree: Bin = Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) ``` -`cataF` folds an `S` to an `A`. The algebra sees the node plus its already-folded children **as a +`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: CataF[BinF, Bin, Int] = - Schemes.cataF[BinF, Bin, Int] { (_, folded) => +val sumLeavesF: Cata[BinF, Bin, Int] = + Schemes.cata[BinF, Bin, Int] { (_, folded) => folded match case BinF.LeafF(n) => n case BinF.BranchF(l, r) => l + r @@ -85,19 +85,19 @@ val sumLeavesF: CataF[BinF, Bin, Int] = sumLeavesF.get(binTree) ``` -`anaF` builds an `S` from a seed via a single fused coalgebra `Seed => F[Seed]`; `Embed` glues each -layer. `hyloF` is the **fused** refold (`Seed => A`, no intermediate `Bin`) and needs only +`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 // build a right spine of (n+1) unit leaves -val buildBin = Schemes.anaF[BinF, Int, Bin] { n => +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: Getter[Int, Int] = - Schemes.hyloF[BinF, Int, Int]( + Schemes.hylo[BinF, Int, Int]( coalg = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1), alg = (_, folded) => folded match @@ -112,15 +112,15 @@ countLeavesF.get(3) // same count, fused — no Bin materiali countLeavesF.get(1000000) // stack-safe: the heap machine, O(depth) heap ``` -`cataF`/`hyloF` are Getter-shaped and `anaF` is Review-shaped, so +`cata`/`hylo` are Getter-shaped and `ana` is Review-shaped, so they compose with the rest of the optic algebra via `andThen` and `cross` (the materializing -`anaF(…).cross(cataF(…))` equals the fused `hyloF` for a pure algebra — the hylo law). They run on +`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)). **Choosing a path:** reach for `cata`/`ana`/`hylo` (default) when you want zero -boilerplate; reach for `cataF`/`anaF`/`hyloF` when you want the algebra to be type-checked against +boilerplate; reach for `cata`/`ana`/`hylo` when you want the algebra to be type-checked against named constructors. Deriving `Project`/`Embed` from the `S`↔`F` correspondence is future work; today they are hand-written (as above). @@ -129,7 +129,7 @@ they are hand-written (as above). Because the schemes are `Getter`s, 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. Read-only optics compose `Getter`-to-`Getter`, so wrap the lens's read in a `Getter` (or just read at the leaf, -`cataF(alg).get(lens.get(record))`) — the same composed lens still *writes* the field back: +`cata(alg).get(lens.get(record))`) — the same composed lens still *writes* the field back: ```scala mdoc:silent import dev.constructive.eo.optics.Lens @@ -172,12 +172,12 @@ The decorated schemes are **one sum/product symmetry**, and eo ships it as a voc | **futu** | multiple layers per step (`Coattr`) | iterated sum | `Decor.futu` | | zygo / dyna / … | user-written `Decor` values | — | (yours — example below) | -`paraF` pairs each child slot with its **original subterm** — taken from the nodes the machine +`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.paraF[BinF, Bin, Int] { (_, layer) => +val leftLeafBranches = Schemes.para[BinF, Bin, Int] { (_, layer) => layer match case BinF.LeafF(_) => 0 case BinF.BranchF((ls, l), (_, r)) => @@ -189,14 +189,14 @@ val leftLeafBranches = Schemes.paraF[BinF, Bin, Int] { (_, layer) => leftLeafBranches.get(binTree) ``` -`apoF` lets the coalgebra answer any slot with an **already-finished subtree** — grafted into the +`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 val cached: Bin = binTree // an expensive subtree you already have -val patched = Schemes.apoF[BinF, Int, Bin] { n => +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 } @@ -206,7 +206,7 @@ val patched = Schemes.apoF[BinF, Int, Bin] { n => patched.reverseGet(2) ``` -`histoF` gives the algebra each child's **entire decorated history** (`Attr[F, A]`: the result +`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): @@ -214,7 +214,7 @@ O(n) `Attr` cells): import dev.constructive.eo.schemes.{Attr, Coattr} // add each branch's grandchildren-through-history to its result -val withGrand = Schemes.histoF[BinF, Bin, Int] { (_, layer) => +val withGrand = Schemes.histo[BinF, Bin, Int] { (_, layer) => layer match case BinF.LeafF(n) => n case BinF.BranchF(l, r) => @@ -225,11 +225,11 @@ val withGrand = Schemes.histoF[BinF, Bin, Int] { (_, layer) => } ``` -`futuF` lets the coalgebra emit **several layers per step** (`Coattr.Roll` layers are unrolled +`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.futuF[BinF, Int, Bin] { n => +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)) } @@ -237,11 +237,11 @@ val twoAtATime = Schemes.futuF[BinF, Int, Bin] { n => ### Fusion is composition: `cross` -`anaF`/`cataF` return concrete citizens (`AnaF`/`CataF`) carrying their (co)algebras, so the +`ana`/`cata` return concrete citizens (`Ana`/`Cata`) carrying their (co)algebras, so the build-output→read-input composition — the seam core's `Optic.cross` names — **fuses**: one single-pass machine, each node built once and folded immediately, no full-tree retention and no -second traversal. (`hyloF` remains the zero-`S` spelling for seed-typed algebras; binding an -`AnaF` to a wider type falls back to the generic, materializing `cross` — extensionally equal.) +second traversal. (`hylo` remains the zero-`S` spelling for seed-typed algebras; binding an +`Ana` to a wider type falls back to the generic, materializing `cross` — extensionally equal.) ```scala mdoc:silent val zooExpand: Int => BinF[Int] = n => @@ -249,7 +249,7 @@ val zooExpand: Int => BinF[Int] = n => val zooSum: (Bin, BinF[Int]) => Int = (_, fa) => fa match { case BinF.LeafF(n) => n; case BinF.BranchF(l, r) => l + r } -val fusedLeafSum = Schemes.anaF[BinF, Int, Bin](zooExpand).cross(Schemes.cataF(zooSum)) +val fusedLeafSum = Schemes.ana[BinF, Int, Bin](zooExpand).cross(Schemes.cata(zooSum)) ``` ```scala mdoc @@ -262,7 +262,7 @@ The generality that droste exposes as `gcata`/`gana` lives here as the **public a decoration is an optic over the `BiAffine` carrier (fold side: `from` = *gather*; unfold side: `to` = *scatter*, `from` on `Step` = the seed-injecting unit). A zygomorphism — the algebra consults a helper fold alongside each child's result — is a user-written gather value, consumed -by the same `cataF(decor)(galg)` driver as the named members: +by the same `cata(decor)(galg)` driver as the named members: ```scala mdoc:silent import dev.constructive.eo.schemes.DecorGather @@ -284,7 +284,7 @@ val leafCount: BinF[Int] => Int = { case BinF.LeafF(_) => 1; case BinF.BranchF(l, r) => l + r } // sum, with the helper count available at every branch -val sumWithCount = Schemes.cataF[BinF, Bin, (Int, Int), Int](zygo(leafCount)) { (_, layer) => +val sumWithCount = Schemes.cata[BinF, Bin, (Int, Int), Int](zygo(leafCount)) { (_, layer) => layer match case BinF.LeafF(n) => n case BinF.BranchF((_, l), (cr, r)) => l + r + 0 * cr // helper in scope per child @@ -294,14 +294,14 @@ val sumWithCount = Schemes.cataF[BinF, Bin, (Int, Int), Int](zygo(leafCount)) { User-written values run the generic decoration route (one decoration dispatch + `Step` per node); the named values dispatch to native engine routes. -### Effectful steps: `cataFM` / `anaFM` / `hyloFM` +### Effectful steps: `cataM` / `anaM` / `hyloM` 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). Results are `Forget[M]`-carried citizens consumed via `.run`, and -`AnaFM.andThen(CataFM)` fuses (there `andThen` genuinely is the focus seam): +`AnaM.andThen(CataM)` fuses (there `andThen` genuinely is the focus seam): ```scala mdoc:silent import cats.data.State @@ -313,8 +313,8 @@ def fetchLayer(n: Int): Counted[BinF[Int]] = val countedLeafSum = Schemes - .anaFM[Counted, BinF, Int, Bin](fetchLayer) - .andThen(Schemes.cataFM[Counted, BinF, Bin, Int]((s, fa) => State.pure(zooSum(s, fa)))) + .anaM[Counted, BinF, Int, Bin](fetchLayer) + .andThen(Schemes.cataM[Counted, BinF, Bin, Int]((s, fa) => State.pure(zooSum(s, fa)))) ``` ```scala mdoc From 9af72cb014180c9c13b6ef457ae37012a0c35301 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 10:15:18 +0200 Subject: [PATCH 21/61] =?UTF-8?q?refactor(schemes):=20Decor=20=E2=86=92=20?= =?UTF-8?q?Gather/Scatter;=20para/apo=20decorations=20demoted=20to=20law?= =?UTF-8?q?=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decoration family gets its literature name: the fold side is Gather, the unfold side Scatter (droste's Gather/Scatter, Haskell recursion-schemes' distCata/distHisto/distFutu). Type aliases DecorGather/DecorScatter → Gather/Scatter; the Decor object splits into object Gather (cata, histo) and object Scatter (ana, futu); the file is Decorations.scala (decoration data Attr/Coattr + decoration optics). Decor.para and Decor.apo are REMOVED from the shipped surface: their generic routes are deliberately inferior by construction (para's gather re-embeds every subterm — droste's Gather.para; apo's scatter re-walks grafts through Project — distApo, O(graft)) and the native Schemes.para / Schemes.apo engines subsume them. Their decoration semantics survive as law fixtures in GatherScatterLawsSpec (paraGather / apoScatter), still pinning native == generic-route equivalence end-to-end. Specs renamed to match: DecorLawsSpec → GatherScatterLawsSpec, DecorSpec → DecorationsSpec. Site docs updated; docs/plans keep the historical names. All 509 tests green; mdoc clean. Co-Authored-By: Claude Fable 5 --- .../constructive/eo/bench/SchemesBench.scala | 2 +- .../eo/bench/fixture/SchemesFixtures.scala | 6 +- .../dev/constructive/eo/accessor/Graft.scala | 2 +- .../{Decor.scala => Decorations.scala} | 109 +++++++----------- .../dev/constructive/eo/schemes/Schemes.scala | 49 ++++---- ...{DecorSpec.scala => DecorationsSpec.scala} | 2 +- ...Spec.scala => GatherScatterLawsSpec.scala} | 107 ++++++++++------- site/docs/schemes.md | 20 ++-- 8 files changed, 148 insertions(+), 149 deletions(-) rename schemes/src/main/scala/dev/constructive/eo/schemes/{Decor.scala => Decorations.scala} (59%) rename schemes/src/test/scala/dev/constructive/eo/schemes/{DecorSpec.scala => DecorationsSpec.scala} (96%) rename schemes/src/test/scala/dev/constructive/eo/schemes/{DecorLawsSpec.scala => GatherScatterLawsSpec.scala} (62%) 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 9e946887..6dd7982b 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -93,7 +93,7 @@ class SchemesBench extends JmhDefaults: // 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 (Decor.apo), not to droste.zoo.apo. + // 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) 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 8b07f82d..92077e80 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 @@ -112,7 +112,7 @@ object SchemesFixtures: import higherkindness.droste.{CVAlgebra, CVCoalgebra, RAlgebra, RCoalgebra} import higherkindness.droste.data.{Attr => DAttr, Coattr => DCoattr} - import dev.constructive.eo.schemes.{Attr => EoAttr, Coattr => EoCoattr, DecorGather} + import dev.constructive.eo.schemes.{Attr => EoAttr, Coattr => EoCoattr, Gather} import dev.constructive.eo.data.BiAffine import dev.constructive.eo.optics.Optic @@ -157,10 +157,10 @@ object SchemesFixtures: else BinF.NodeF(DCoattr.pure(d - 1), DCoattr.pure(d - 1)) } - // generic decoration route: a USER-WRITTEN id gather (not the Decor.cata + // generic decoration route: a USER-WRITTEN id gather (not the Gather.cata // singleton, so the driver cannot take the identity fast path) — D4's // dispatch-cost honesty number. - val userIdGather: DecorGather[BinF, Int, Int] = + val userIdGather: Gather[BinF, Int, Int] = new Optic[Unit, Int, Unit, Int, BiAffine]: type X = (Unit, BinF[Int]) def to(u: Unit): BiAffine[X, Unit] = diff --git a/core/src/main/scala/dev/constructive/eo/accessor/Graft.scala b/core/src/main/scala/dev/constructive/eo/accessor/Graft.scala index 7f60366a..dc28ea94 100644 --- a/core/src/main/scala/dev/constructive/eo/accessor/Graft.scala +++ b/core/src/main/scala/dev/constructive/eo/accessor/Graft.scala @@ -13,7 +13,7 @@ import data.{Fst, Snd} * arm. * * The payload *meaning* of `done` is pinned per optic value via the existential `X` (`Fst[X]`), - * not by this capability — see the `Decor` family in `cats-eo-schemes`. + * not by this capability — see the `Gather`/`Scatter` decoration optics in `cats-eo-schemes`. * * @tparam F * the carrier diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Decorations.scala similarity index 59% rename from schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala rename to schemes/src/main/scala/dev/constructive/eo/schemes/Decorations.scala index 6f0fd263..1befb361 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Decor.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Decorations.scala @@ -44,68 +44,60 @@ enum Coattr[F[_], A]: case Roll(layer: F[Coattr[F, A]]) // =========================================================================================== -// The Decor family — decorations as optics over the BiAffine carrier. +// Gather / Scatter — the decoration optics, over the BiAffine carrier. // -// A generalized scheme's decoration is an optic whose existential leftover is one F-layer. +// A generalized scheme's decoration is an optic whose existential leftover is one F-layer +// (the names echo droste's Gather/Scatter and recursion-schemes' distCata/distHisto/...). // Sides are pinned in the TYPE, eo-style (read-only/build-only citizens): // -// - Fold side (DecorGather): build-only. `from` is the GATHER — it consumes +// - Fold side (Gather): build-only. `from` is the GATHER — it consumes // `Step(layer: F[W], result: A)` and produces the decoration `W` (histo's gather is // literally the `Attr` constructor). The read side is vestigial (throws, the // `Unfold.algebra` precedent); `Done` never occurs on this side. // -// - Unfold side (DecorScatter): a full citizen. `to` is the SCATTER — an affine match +// - Unfold side (Scatter): a full citizen. `to` is the SCATTER — an affine match // answering each slot with `Step(_, seed)` (call the coalgebra) or `Done(layer: F[W])` // (a prebuilt layer; unroll it, no coalgebra call). `from` on the Step arm is the -// POINTED unit — the seed injection `A => W` (gana's `pure`: ana = identity, apo = -// `Right`, futu = `Coattr.Pure`), giving the unit law `to(from(Step((), a))) == -// Step((), a)`. +// POINTED unit — the seed injection `A => W` (gana's `pure`: ana = identity, futu = +// `Coattr.Pure`), giving the unit law `to(from(Step((), a))) == Step((), a)`. // // X pinning per shape: gather side X = (Unit, F[W]) (Snd = the one-F-layer context); scatter // side X = (F[W], Unit) (Fst = the prebuilt-layer Done payload). The generic drivers in // [[Schemes]] are fully typed against these refinements — no casts at the seam. // -// NOTE (generic vs native routes): `Decor.apo`'s generic scatter unrolls a grafted subtree -// through `Project` (droste's distApo — O(graft)). The O(1) graft is the privilege of the -// NATIVE `apo` engine, which prefills result slots from apo's `Done` directly and never -// consults this value. Likewise `Decor.para`'s generic gather re-embeds the subterm it pairs -// (droste's Gather.para); the native `para` pairs subterms from the walked nodes instead. +// para and apo have NO generic values here: their generic routes are deliberately inferior +// (para's gather would re-embed each subterm — droste's Gather.para; apo's scatter would +// re-walk grafts through Project — distApo, O(graft)), and the native `Schemes.para` / +// `Schemes.apo` engines subsume them. Their decoration semantics survive as law fixtures in +// the test suite (GatherScatterLawsSpec), pinning the native routes to the definitions. // =========================================================================================== -/** Fold-side decoration: gather-only (build-only member). `from` = gather. */ -type DecorGather[F[_], W, A] = +/** Fold-side decoration optic: gather-only (build-only member). `from` = gather. */ +type Gather[F[_], W, A] = optics.Optic[Unit, W, Unit, A, data.BiAffine] { type X = (Unit, F[W]) } -/** Unfold-side decoration: scatter (`to`, an affine match) + pointed unit (`from` on Step). */ -type DecorScatter[F[_], W, A] = +/** Unfold-side decoration optic: scatter (`to`, an affine match) + pointed unit (`from` on Step). + */ +type Scatter[F[_], W, A] = optics.Optic[W, W, A, A, data.BiAffine] { type X = (F[W], Unit) } -/** Named decoration values — the recursion-scheme zoo's vocabulary. */ -object Decor: - import cats.Functor - +/** Named fold-side decoration values. */ +object Gather: import data.BiAffine import data.BiAffine.{Done, Step} import optics.Optic private def vestigialRead(name: String): Nothing = throw new UnsupportedOperationException( - s"Decor.$name is gather-only (build-only): its read side is vestigial by specification" + s"Gather.$name is gather-only (build-only): its read side is vestigial by specification" ) private def foldSideDone(name: String): Nothing = throw new UnsupportedOperationException( - s"Decor.$name is a fold-side decoration: Done never occurs on the gather seam" - ) - - private def scatterSideDone(name: String): Nothing = - throw new UnsupportedOperationException( - s"Decor.$name: the pointed unit (from) is inhabited on the Step arm only" + s"Gather.$name is a fold-side decoration: Done never occurs on the gather seam" ) - // ----- fold side (gather) ------------------------------------------------ - - private def mkCata[F[_], A]: DecorGather[F, A, A] = + private def mkCata[F[_], A]: Gather[F, A, A] = new Optic[Unit, A, Unit, A, BiAffine]: type X = (Unit, F[A]) def to(u: Unit): BiAffine[X, Unit] = vestigialRead("cata") @@ -118,23 +110,9 @@ object Decor: /** The undecorated fold — gather keeps the result, discards the layer. Identity-stable singleton: * the generic driver recognises it and takes the direct (decoration-free) route. */ - def cata[F[_], A]: DecorGather[F, A, A] = cataAny.asInstanceOf[DecorGather[F, A, A]] + def cata[F[_], A]: Gather[F, A, A] = cataAny.asInstanceOf[Gather[F, A, A]] - /** Paramorphism decoration: each child slot pairs the original subterm with its result. - * - * Generic-route honesty: this gather *re-embeds* the subterm from the layer (droste's - * `Gather.para`) — the native `para` avoids that by pairing subterms from the nodes the machine - * already walks. - */ - def para[F[_]: Functor, S, A](using E: Embed[F, S]): DecorGather[F, (S, A), A] = - new Optic[Unit, (S, A), Unit, A, BiAffine]: - type X = (Unit, F[(S, A)]) - def to(u: Unit): BiAffine[X, Unit] = vestigialRead("para") - def from(xb: BiAffine[X, A]): (S, A) = xb match - case s: Step[X, A] => (E.embed(Functor[F].map(s.snd)(_._1)), s.b) - case _: Done[X, A] => foldSideDone("para") - - private def mkHisto[F[_], A]: DecorGather[F, Attr[F, A], A] = + private def mkHisto[F[_], A]: Gather[F, Attr[F, A], A] = new Optic[Unit, Attr[F, A], Unit, A, BiAffine]: type X = (Unit, F[Attr[F, A]]) def to(u: Unit): BiAffine[X, Unit] = vestigialRead("histo") @@ -147,12 +125,21 @@ object Decor: /** Histomorphism decoration — the gather IS the [[Attr]] constructor: each node keeps its result * plus its children's full decorated histories. */ - def histo[F[_], A]: DecorGather[F, Attr[F, A], A] = - histoAny.asInstanceOf[DecorGather[F, Attr[F, A], A]] + def histo[F[_], A]: Gather[F, Attr[F, A], A] = + histoAny.asInstanceOf[Gather[F, Attr[F, A], A]] - // ----- unfold side (scatter + pointed unit) ------------------------------- +/** Named unfold-side decoration values. */ +object Scatter: + import data.BiAffine + import data.BiAffine.{Done, Step} + import optics.Optic + + private def scatterSideDone(name: String): Nothing = + throw new UnsupportedOperationException( + s"Scatter.$name: the pointed unit (from) is inhabited on the Step arm only" + ) - private def mkAna[F[_], A]: DecorScatter[F, A, A] = + private def mkAna[F[_], A]: Scatter[F, A, A] = new Optic[A, A, A, A, BiAffine]: type X = (F[A], Unit) def to(w: A): BiAffine[X, A] = new Step[X, A]((), w) @@ -165,23 +152,9 @@ object Decor: /** The undecorated unfold — every slot is a seed; the unit is the identity. Identity-stable * singleton (the generic driver takes the direct route on it). */ - def ana[F[_], A]: DecorScatter[F, A, A] = anaAny.asInstanceOf[DecorScatter[F, A, A]] + def ana[F[_], A]: Scatter[F, A, A] = anaAny.asInstanceOf[Scatter[F, A, A]] - /** Apomorphism decoration, generic route: `Right(seed)` keeps unfolding, `Left(s)` answers with - * the grafted subtree's projected layer — distApo, O(graft) through `Project`. The O(1) graft is - * the native `apo` engine's privilege; it never consults this value. - */ - def apo[F[_]: Functor, S, A](using P: Project[F, S]): DecorScatter[F, Either[S, A], A] = - new Optic[Either[S, A], Either[S, A], A, A, BiAffine]: - type X = (F[Either[S, A]], Unit) - def to(w: Either[S, A]): BiAffine[X, A] = w match - case Right(a) => new Step[X, A]((), a) - case Left(s) => new Done[X, A](Functor[F].map(P.project(s))(Left(_))) - def from(xb: BiAffine[X, A]): Either[S, A] = xb match - case s: Step[X, A] => Right(s.b) - case _: Done[X, A] => scatterSideDone("apo") - - private def mkFutu[F[_], A]: DecorScatter[F, Coattr[F, A], A] = + private def mkFutu[F[_], A]: Scatter[F, Coattr[F, A], A] = new Optic[Coattr[F, A], Coattr[F, A], A, A, BiAffine]: type X = (F[Coattr[F, A]], Unit) def to(w: Coattr[F, A]): BiAffine[X, A] = w match @@ -196,5 +169,5 @@ object Decor: /** Futumorphism decoration — `Pure(seed)` calls the coalgebra, `Roll(layer)` unrolls the prebuilt * layer without a call; the unit is `Coattr.Pure`. */ - def futu[F[_], A]: DecorScatter[F, Coattr[F, A], A] = - futuAny.asInstanceOf[DecorScatter[F, Coattr[F, A], A]] + def futu[F[_], A]: Scatter[F, Coattr[F, A], A] = + futuAny.asInstanceOf[Scatter[F, Coattr[F, A], A]] 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 fc540b73..6baec97f 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -15,9 +15,9 @@ import optics.{Getter, Optic, Review} * retention). * - The zoo: [[para]] (subterms paired from the walked nodes), [[apo]] (O(1) graft), [[histo]] / * [[futu]] (course-of-value / multi-layer, via [[Attr]] / [[Coattr]]). Decorated generically - * through the [[Decor]] optic family (gather/scatter over the `BiAffine` carrier) — - * zygo/dyna/chrono are user-written [[DecorGather]] / [[DecorScatter]] values fed to the - * generic [[cata]] / [[ana]] overloads. + * through the [[Gather]]/[[Scatter]] decoration optics (over the `BiAffine` carrier) — + * zygo/dyna/chrono are user-written [[Gather]] / [[Scatter]] values fed to the generic + * [[cata]] / [[ana]] overloads. * - The M-generic drivers [[cataM]] / [[anaM]] / [[hyloM]] run the same machine lifted through * `Monad[M].tailRecM` (effectful layers, single-pass linear Ms). * @@ -391,23 +391,23 @@ object Schemes: def cata[F[_], S, A]( alg: (S, F[A]) => A )(using F: Traverse[F], P: Project[F, S]): Cata[F, S, A] = - new Cata[F, S, A](cata[F, S, A, A](Decor.cata[F, A])(alg).get, alg) + new Cata[F, S, A](cata[F, S, A, A](Gather.cata[F, A])(alg).get, alg) /** Generalized (decorated) catamorphism — the gcata of the typed path, with the decoration - * supplied as a [[DecorGather]] optic value. Interior nodes apply `gather ∘ galg` (the - * decoration's `from` consuming `Step(layer, result)`); the **root applies `galg` alone** - * (droste's `gcata` shape). The named zoo members are instances: `cata(alg)` routes here with - * [[Decor.cata]] (recognised by identity — the direct, decoration-free engine path), `histo` - * with [[Decor.histo]]; user-written decorations (zygo, dyna, …) run the generic route, which - * pays one decoration dispatch + `Step` per node. + * supplied as a [[Gather]] optic value. Interior nodes apply `gather ∘ galg` (the decoration's + * `from` consuming `Step(layer, result)`); the **root applies `galg` alone** (droste's `gcata` + * shape). The named zoo members are instances: `cata(alg)` routes here with [[Gather.cata]] + * (recognised by identity — the direct, decoration-free engine path), `histo` with + * [[Gather.histo]]; user-written decorations (zygo, dyna, …) run the generic route, which pays + * one decoration dispatch + `Step` per node. * * (type-param order: `[F, S, W, A]` — compare [[ana]] `[F, A, W, S]`, which mirrors these in * input-before-output order: `A` is the input seed there, `S` the built output.) */ def cata[F[_], S, W, A]( - decor: DecorGather[F, W, A] + decor: Gather[F, W, A] )(galg: (S, F[W]) => A)(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = - if decor.asInstanceOf[AnyRef] eq Decor.cata[F, A] then + if decor.asInstanceOf[AnyRef] eq Gather.cata[F, A] then // W =:= A by construction of the singleton — the direct engine path, no decoration cost. val alg = galg.asInstanceOf[(S, F[A]) => A] Getter[S, A]( @@ -445,7 +445,7 @@ object Schemes: * decorated history** ([[Attr]]: result + that child's own decorated layer). * * Native route: the combine builds the `Attr` directly, the root projects its head — one less - * dispatch than the generic [[Decor.histo]] route (whose `Step` is EA-elided: B/op identical; + * dispatch than the generic [[Gather.histo]] route (whose `Step` is EA-elided: B/op identical; * law-pinned equal in `DecorLawsSpec`). The remaining gap to droste's histo (558k vs 361k B/op * on the 8k-node fixture) is the stack-safe machine's per-node child array — droste's zoo * recursion is stack-UNSAFE plain recursion; the ~24 B/node is the price of the guarantee. @@ -472,7 +472,7 @@ object Schemes: def ana[F[_], Seed, S]( coalg: Seed => F[Seed] )(using F: Traverse[F], E: Embed[F, S]): Ana[F, Seed, S] = - new Ana[F, Seed, S](ana[F, Seed, Seed, S](Decor.ana[F, Seed])(coalg).reverseGet, coalg) + new Ana[F, Seed, S](ana[F, Seed, Seed, S](Scatter.ana[F, Seed])(coalg).reverseGet, coalg) /** Single-pass paired machine backing the fused `Ana.cross(Cata)`: each node is built once (the * algebra is node-supplied — construction is semantically required), folded immediately, and @@ -513,7 +513,8 @@ object Schemes: * `Right(seed)` (keep unfolding) or `Left(s)` (an **already-finished subtree**). Native O(1) * graft: `Left` subtrees are prefilled into their result slots **by reference** — never * recursed, never projected ([[foldLayeredOr]]). Contrast droste's scatter-apo, which re-walks - * grafts through `project` (O(graft) per graft — the route [[Decor.apo]] documents). Stack-safe. + * grafts through `project` (O(graft) per graft — the distApo route, kept only as a law fixture + * in the test suite). Stack-safe. */ def apo[F[_], A, S]( coalg: A => F[Either[S, A]] @@ -532,8 +533,8 @@ object Schemes: * coalgebra call). * * Native route: the expand matches `Coattr` directly — one less dispatch than the generic - * [[Decor.futu]] route (whose per-slot `Step` is EA-elided: B/op identical; law-pinned equal in - * `DecorLawsSpec`). The gap to droste's futu (655k vs 459k B/op) is the stack-safe machine's + * [[Scatter.futu]] route (whose per-slot `Step` is EA-elided: B/op identical; law-pinned equal + * in `DecorLawsSpec`). The gap to droste's futu (655k vs 459k B/op) is the stack-safe machine's * per-node child array — droste's zoo recursion is stack-unsafe. */ def futu[F[_], A, S]( @@ -549,22 +550,22 @@ object Schemes: Review[S, A](a => build(Coattr.Pure(a))) /** Generalized (decorated) anamorphism — the gana of the typed path, with the decoration supplied - * as a [[DecorScatter]] optic value. Each `W` slot is scattered (the decoration's `to`): + * as a [[Scatter]] optic value. Each `W` slot is scattered (the decoration's `to`): * `Step(_, seed)` calls `gcoalg`, `Done(layer)` unrolls the prebuilt layer with **no coalgebra * call**. The root seed enters through the decoration's pointed unit (`from` on the Step arm — - * gana's `pure`). `ana(coalg)` routes here with [[Decor.ana]] (identity-recognised direct path); - * `futu` with [[Decor.futu]]; `Decor.apo` runs the generic distApo route — the O(1) graft - * belongs to the native `apo` engine. + * gana's `pure`). `ana(coalg)` routes here with [[Scatter.ana]] (identity-recognised direct + * path); `futu` with [[Scatter.futu]]. (apo has no shipped Scatter value — distApo is inferior + * by construction; the O(1) graft belongs to the native `apo` engine. * - * For user-written [[DecorScatter]] values, `Done.fst` MUST carry `F[W]` at runtime — the engine + * For user-written [[Scatter]] values, `Done.fst` MUST carry `F[W]` at runtime — the engine * unrolls it directly as the next layer. * * (type-param order: compare [[cata]] `[F, S, W, A]` — the fold mirror swaps `Seed`/`A`.) */ def ana[F[_], A, W, S]( - decor: DecorScatter[F, W, A] + decor: Scatter[F, W, A] )(gcoalg: A => F[W])(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = - if decor.asInstanceOf[AnyRef] eq Decor.ana[F, A] then + if decor.asInstanceOf[AnyRef] eq Scatter.ana[F, A] then // W =:= A by construction of the singleton — the direct engine path. val coalg = gcoalg.asInstanceOf[A => F[A]] Review[S, A]( diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorationsSpec.scala similarity index 96% rename from schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala rename to schemes/src/test/scala/dev/constructive/eo/schemes/DecorationsSpec.scala index 08bfe807..468de79c 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorationsSpec.scala @@ -9,7 +9,7 @@ import schemes.samples.BinF * structural equality over a real pattern functor. The zoo members that consume them (`histo` / * `futu`) carry the behavioural coverage. */ -class DecorSpec extends Specification: +class DecorationsSpec extends Specification: // A decorated branch: results 1 and 3 at the leaves, 4 at the root. private val decorated: Attr[BinF, Int] = diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala similarity index 62% rename from schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala rename to schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala index e1e7acc5..2add00fc 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala @@ -8,12 +8,12 @@ import data.BiAffine.{Done, Step} import optics.Optic import schemes.samples.{Bin, BinF} -/** Decoration laws — the per-value equations of the [[Decor]] vocabulary, plus the +/** Decoration laws — the per-value equations of the [[Gather]]/[[Scatter]] vocabulary, plus the * behaviour-identity of the re-derived `cata`/`ana` (the identity fast path must agree with the * generic decoration route, proven by running a *fresh* user-written id decoration through the * generic route and comparing). */ -class DecorLawsSpec extends Specification: +class GatherScatterLawsSpec extends Specification: private val tree: Bin = Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Leaf(3)) @@ -23,74 +23,99 @@ class DecorLawsSpec extends Specification: case BinF.LeafF(n) => n case BinF.BranchF(l, r) => l + r + // ----- demoted decorations as LAW FIXTURES --------------------------------- + // para's gather and apo's scatter have no shipped values (their generic routes + // are deliberately inferior: re-embed / distApo) — their decoration semantics + // live HERE, pinning the native Schemes.para / Schemes.apo engines. + + private def paraGather[F[_]: cats.Functor, S, A](using E: Embed[F, S]): Gather[F, (S, A), A] = + new Optic[Unit, (S, A), Unit, A, BiAffine]: + type X = (Unit, F[(S, A)]) + def to(u: Unit): BiAffine[X, Unit] = + throw new UnsupportedOperationException("gather-only") + def from(xb: BiAffine[X, A]): (S, A) = xb match + case s: Step[X, A] => (E.embed(cats.Functor[F].map(s.snd)(_._1)), s.b) + case _: Done[X, A] => throw new UnsupportedOperationException("fold-side Done") + + private def apoScatter[F[_]: cats.Functor, S, A](using + P: Project[F, S] + ): Scatter[F, Either[S, A], A] = + new Optic[Either[S, A], Either[S, A], A, A, BiAffine]: + type X = (F[Either[S, A]], Unit) + def to(w: Either[S, A]): BiAffine[X, A] = w match + case Right(a) => new Step[X, A]((), a) + case Left(s) => new Done[X, A](cats.Functor[F].map(P.project(s))(Left(_))) + def from(xb: BiAffine[X, A]): Either[S, A] = xb match + case s: Step[X, A] => Right(s.b) + case _: Done[X, A] => throw new UnsupportedOperationException("unit on Step only") + // ----- gather-side equations ---------------------------------------------- - "Decor.histo gather == the Attr constructor" >> { + "Gather.histo gather == the Attr constructor" >> { val layer: BinF[Attr[BinF, Int]] = BinF.BranchF(Attr(1, BinF.LeafF(1)), Attr(2, BinF.LeafF(2))) - Decor + Gather .histo[BinF, Int] .from( new Step[(Unit, BinF[Attr[BinF, Int]]), Int](layer, 3) ) === Attr(3, layer) } - "Decor.para gather == (re-embedded subterm, result)" >> { + "the para gather fixture == (re-embedded subterm, result)" >> { val layer: BinF[(Bin, Int)] = BinF.BranchF((Bin.Leaf(1), 1), (Bin.Leaf(2), 2)) - Decor - .para[BinF, Bin, Int] + paraGather[BinF, Bin, Int] .from( new Step[(Unit, BinF[(Bin, Int)]), Int](layer, 3) ) === (Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), 3) } - "Decor.cata gather == keep the result, discard the layer" >> { - Decor + "Gather.cata gather == keep the result, discard the layer" >> { + Gather .cata[BinF, Int] .from( new Step[(Unit, BinF[Int]), Int](BinF.BranchF(1, 2), 9) ) === 9 } - "Decor.cata is identity-stable across instantiations (the fast-path dispatch key)" >> { - (Decor.cata[BinF, Int].asInstanceOf[AnyRef] eq - Decor.cata[[x] =>> Option[x], String].asInstanceOf[AnyRef]) === true + "Gather.cata is identity-stable across instantiations (the fast-path dispatch key)" >> { + (Gather.cata[BinF, Int].asInstanceOf[AnyRef] eq + Gather.cata[[x] =>> Option[x], String].asInstanceOf[AnyRef]) === true } - "Decor.cata's vestigial read side throws" >> { + "Gather.cata's vestigial read side throws" >> { val thrown = - try { val _ = Decor.cata[BinF, Int].to(()); false } + try { val _ = Gather.cata[BinF, Int].to(()); false } catch case _: UnsupportedOperationException => true thrown === true } // ----- scatter-side equations --------------------------------------------- - "Decor.ana scatters every value as a Step (no Done arm)" >> { - Decor.ana[BinF, Int].to(7) === new Step[(BinF[Int], Unit), Int]((), 7) + "Scatter.ana scatters every value as a Step (no Done arm)" >> { + Scatter.ana[BinF, Int].to(7) === new Step[(BinF[Int], Unit), Int]((), 7) } - "Decor.ana satisfies the unit law: to(from(Step((), a))) == Step((), a)" >> { - val d = Decor.ana[BinF, Int] + "Scatter.ana satisfies the unit law: to(from(Step((), a))) == Step((), a)" >> { + val d = Scatter.ana[BinF, Int] d.to(d.from(new Step[(BinF[Int], Unit), Int]((), 5))) === new Step[(BinF[Int], Unit), Int]((), 5) } - "Decor.futu scatters Pure as Step and Roll as Done(layer)" >> { - val d = Decor.futu[BinF, Int] + "Scatter.futu scatters Pure as Step and Roll as Done(layer)" >> { + val d = Scatter.futu[BinF, Int] val layer: BinF[Coattr[BinF, Int]] = BinF.BranchF(Coattr.Pure(1), Coattr.Pure(2)) (d.to(Coattr.Pure(4)) === new Step[(BinF[Coattr[BinF, Int]], Unit), Int]((), 4)) .and(d.to(Coattr.Roll(layer)) === new Done[(BinF[Coattr[BinF, Int]], Unit), Int](layer)) } - "Decor.futu's unit is Coattr.Pure" >> { - val d = Decor.futu[BinF, Int] + "Scatter.futu's unit is Coattr.Pure" >> { + val d = Scatter.futu[BinF, Int] d.from(new Step[(BinF[Coattr[BinF, Int]], Unit), Int]((), 4)) === Coattr.Pure(4) } - "Decor.apo (generic route) scatters Right as Step and Left as the projected layer" >> { - val d = Decor.apo[BinF, Bin, Int] + "the apo scatter fixture (distApo) scatters Right as Step and Left as the projected layer" >> { + val d = apoScatter[BinF, Bin, Int] val grafted = Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)) (d.to(Right(7)) === new Step[(BinF[Either[Bin, Int]], Unit), Int]((), 7)) .and( @@ -100,16 +125,16 @@ class DecorLawsSpec extends Specification: ) } - "Decor.apo's unit is Right" >> { - val d = Decor.apo[BinF, Bin, Int] + "the apo scatter fixture's unit is Right" >> { + val d = apoScatter[BinF, Bin, Int] d.from(new Step[(BinF[Either[Bin, Int]], Unit), Int]((), 7)) === Right(7) } // ----- re-derivation behaviour identity ------------------------------------ - // A FRESH user-written id gather — structurally Decor.cata but a distinct value, + // A FRESH user-written id gather — structurally Gather.cata but a distinct value, // so the generic driver cannot take the identity fast path. - private val freshIdGather: DecorGather[BinF, Int, Int] = + private val freshIdGather: Gather[BinF, Int, Int] = new Optic[Unit, Int, Unit, Int, BiAffine]: type X = (Unit, BinF[Int]) def to(u: Unit): BiAffine[X, Unit] = throw new UnsupportedOperationException("vestigial") @@ -117,7 +142,7 @@ class DecorLawsSpec extends Specification: case s: Step[X, Int] => s.b case _: Done[X, Int] => throw new UnsupportedOperationException("fold-side Done") - private val freshIdScatter: DecorScatter[BinF, Int, Int] = + private val freshIdScatter: Scatter[BinF, Int, Int] = new Optic[Int, Int, Int, Int, BiAffine]: type X = (BinF[Int], Unit) def to(w: Int): BiAffine[X, Int] = new Step[X, Int]((), w) @@ -137,30 +162,30 @@ class DecorLawsSpec extends Specification: Schemes.ana[BinF, Int, Bin](expand).reverseGet(5) } - "histo through Decor.histo: heads-only course-of-value == cata" >> { + "histo through Gather.histo: heads-only course-of-value == cata" >> { val viaHisto = Schemes - .cata[BinF, Bin, Attr[BinF, Int], Int](Decor.histo[BinF, Int]) { (s, layer) => + .cata[BinF, Bin, Attr[BinF, Int], Int](Gather.histo[BinF, Int]) { (s, layer) => sumAlg(s, BinF.traverse.map(layer)(_.head)) } .get(tree) viaHisto === Schemes.cata[BinF, Bin, Int](sumAlg).get(tree) } - "futu through Decor.futu: a two-layer-per-step coalgebra builds the right tree" >> { + "futu through Scatter.futu: a two-layer-per-step coalgebra builds the right tree" >> { // Each step on seed n > 1 emits TWO layers at once: a branch whose left side is // a prebuilt leaf layer (Roll) and whose right side keeps unfolding (Pure). def coalg(n: Int): BinF[Coattr[BinF, Int]] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) val built = Schemes - .ana[BinF, Int, Coattr[BinF, Int], Bin](Decor.futu[BinF, Int])(coalg) + .ana[BinF, Int, Coattr[BinF, Int], Bin](Scatter.futu[BinF, Int])(coalg) .reverseGet(3) built === Bin.Branch(Bin.Leaf(3), Bin.Branch(Bin.Leaf(2), Bin.Leaf(1))) } // ----- native routes == generic decoration routes (the perf-win pins) -------- - "native histo == the generic route at Decor.histo" >> { + "native histo == the generic route at Gather.histo" >> { val alg: (Bin, BinF[Attr[BinF, Int]]) => Int = (s, layer) => sumAlg( s, @@ -173,20 +198,20 @@ class DecorLawsSpec extends Specification: ), ) Schemes.histo[BinF, Bin, Int](alg).get(tree) === - Schemes.cata[BinF, Bin, Attr[BinF, Int], Int](Decor.histo[BinF, Int])(alg).get(tree) + Schemes.cata[BinF, Bin, Attr[BinF, Int], Int](Gather.histo[BinF, Int])(alg).get(tree) } - "native futu == the generic route at Decor.futu" >> { + "native futu == the generic route at Scatter.futu" >> { def coalg(n: Int): BinF[Coattr[BinF, Int]] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) Schemes.futu[BinF, Int, Bin](coalg).reverseGet(4) === - Schemes.ana[BinF, Int, Coattr[BinF, Int], Bin](Decor.futu[BinF, Int])(coalg).reverseGet(4) + Schemes.ana[BinF, Int, Coattr[BinF, Int], Bin](Scatter.futu[BinF, Int])(coalg).reverseGet(4) } // ----- generic-route end-to-end pins -------------------------------------- - "native para == the generic route at Decor.para" >> { + "native para == the generic route at the para gather fixture" >> { // Algebra that uses BOTH the paired subterm and the result: // for a branch, sum child results and add 1 for each child subterm that is a Leaf. // tree = Branch(Branch(Leaf(1), Leaf(2)), Leaf(3)) @@ -201,10 +226,10 @@ class DecorLawsSpec extends Specification: la + ra + (if ls.isInstanceOf[Bin.Leaf] then 1 else 0) + (if rs.isInstanceOf[Bin.Leaf] then 1 else 0) Schemes.para[BinF, Bin, Int](alg).get(tree) === - Schemes.cata[BinF, Bin, (Bin, Int), Int](Decor.para[BinF, Bin, Int])(alg).get(tree) + Schemes.cata[BinF, Bin, (Bin, Int), Int](paraGather[BinF, Bin, Int])(alg).get(tree) } - "native apo == the generic route at Decor.apo (distApo)" >> { + "native apo == the generic route at the apo scatter fixture (distApo)" >> { // Coalg with one graft: at n <= 0 emit a leaf; otherwise graft Bin.Leaf(n) as left child. // The native apo places the graft by reference; the generic route (distApo via project) // rebuilds it — structural equality (==) holds, reference identity (eq) only for native. @@ -213,7 +238,7 @@ class DecorLawsSpec extends Specification: else BinF.BranchF(Left(Bin.Leaf(n)), Right(n - 1)) val nativeResult = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(2) val genericResult = - Schemes.ana[BinF, Int, Either[Bin, Int], Bin](Decor.apo[BinF, Bin, Int])(coalg).reverseGet(2) + Schemes.ana[BinF, Int, Either[Bin, Int], Bin](apoScatter[BinF, Bin, Int])(coalg).reverseGet(2) // Both produce the same tree by value; use == not eq (generic route REBUILDS the graft). nativeResult === genericResult } diff --git a/site/docs/schemes.md b/site/docs/schemes.md index bfaf2e26..f09f0403 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -165,12 +165,12 @@ The decorated schemes are **one sum/product symmetry**, and eo ships it as a voc | scheme | decoration | shape | `Decor` value | |---|---|---|---| -| cata / ana | none | — | `Decor.cata` / `Decor.ana` | -| **para** | child slots carry the original subterms | product | `Decor.para` | -| **apo** | child slots may graft a finished subtree | sum | `Decor.apo` | -| **histo** | full decorated history per child (`Attr`) | iterated product | `Decor.histo` | -| **futu** | multiple layers per step (`Coattr`) | iterated sum | `Decor.futu` | -| zygo / dyna / … | user-written `Decor` values | — | (yours — example below) | +| cata / ana | none | — | `Gather.cata` / `Scatter.ana` | +| **para** | child slots carry the original subterms | product | (native only) | +| **apo** | child slots may graft a finished subtree | sum | (native only) | +| **histo** | full decorated history per child (`Attr`) | iterated product | `Gather.histo` | +| **futu** | multiple layers per step (`Coattr`) | iterated sum | `Scatter.futu` | +| zygo / dyna / … | user-written `Gather`/`Scatter` values | — | (yours — example below) | `para` pairs each child slot with its **original subterm** — taken from the nodes the machine already walks, with no per-node re-`embed`: @@ -258,18 +258,18 @@ fusedLeafSum.get(6) ### Write your own decoration: zygo as a `Decor` value -The generality that droste exposes as `gcata`/`gana` lives here as the **public `Decor` family**: +The generality that droste exposes as `gcata`/`gana` lives here as the **public `Gather`/`Scatter` decoration optics**: a decoration is an optic over the `BiAffine` carrier (fold side: `from` = *gather*; unfold side: `to` = *scatter*, `from` on `Step` = the seed-injecting unit). A zygomorphism — the algebra consults a helper fold alongside each child's result — is a user-written gather value, consumed by the same `cata(decor)(galg)` driver as the named members: ```scala mdoc:silent -import dev.constructive.eo.schemes.DecorGather +import dev.constructive.eo.schemes.Gather import dev.constructive.eo.data.BiAffine import dev.constructive.eo.optics.Optic -def zygo[B](helper: BinF[B] => B): DecorGather[BinF, (B, Int), Int] = +def zygo[B](helper: BinF[B] => B): Gather[BinF, (B, Int), Int] = new Optic[Unit, (B, Int), Unit, Int, BiAffine]: type X = (Unit, BinF[(B, Int)]) def to(u: Unit): BiAffine[X, Unit] = @@ -323,7 +323,7 @@ countedLeafSum.run(6).run(0).value // (service calls, leaf sum) — one fused pa ### The BiAffine carrier -`Decor`'s carrier is new in core: **`BiAffine`** — `Affine`'s data shape worn on the *build* +The decoration optics' carrier is new in core: **`BiAffine`** — `Affine`'s data shape worn on the *build* seam. `Step(context, focus)` keeps going; `Done(payload)` means "this slot is already finished — do not call the coalgebra" (apo grafts a finished subtree, futu unrolls a prebuilt layer). Its laws are the graft-finality and round-trip equations in `cats-eo-laws`. Composition here is From 1ed3a82d03532b0bf3772debb76a57163a355e0a Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 16:07:07 +0200 Subject: [PATCH 22/61] refactor(schemes): zoo package + Machines split + concurrency proof (PR review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses every inline thread on PR #24: - One entity per file in the new schemes.zoo package: Attr, Coattr, Gather, Scatter, Cata, Ana, FoldM, CataM, AnaM (Citizens/CitizensM/ Decorations.scala dissolved). FoldM is now PUBLIC with an open constructor — users can wrap their own S => M[A] as a citizen; CataM/AnaM still extend it (no longer sealed, noted in scaladoc). - Schemes.scala keeps only the factory methods; ALL machinery moved to private[schemes] object Machines (OnStackLimit, EmptyAnyRefs, childrenArr, AscendToken, rebuildLayer(+Paired), foldLayered/Or/M, fusedPairedFold(+M)). Machines' header documents the thread-safety model: every machine allocates its mutable state per invocation (per FORCE on the M path), the only shared values are immutable sentinels. EmptyAnyRefs' scaladoc answers what/who/why-threadsafe directly (the PR question): a zero-length leaf sentinel used by childrenArr in every engine; read-only by construction (store loops bound at arr.length). - The thread-safety claim is now TESTED, both places the review asked: SchemesConcurrencySpec (16 concurrent tasks mixing cata/hylo/ana/ para/histo/futu/cataM[Eval] over shared optics and fixtures) and PlatedConcurrencySpec in tests/ (concurrent Plated transform/universe/ rewrite over a shared structure). Fixed in review of the restructure itself: the concurrency spec's coalgebra guarded at n <= 0, making seed 1 its own child — a non-terminating unfold the engines correctly turn into heap exhaustion. Guard is n <= 1; the suite runs in the default 1GB test heap. All 511 tests green; mdoc clean. Co-Authored-By: Claude Fable 5 --- .../eo/bench/fixture/SchemesFixtures.scala | 2 +- .../constructive/eo/schemes/Machines.scala | 358 +++++++++++++ .../dev/constructive/eo/schemes/Schemes.scala | 483 +++--------------- .../schemes/{Citizens.scala => zoo/Ana.scala} | 33 +- .../constructive/eo/schemes/zoo/AnaM.scala | 27 + .../constructive/eo/schemes/zoo/Attr.scala | 19 + .../constructive/eo/schemes/zoo/Cata.scala | 35 ++ .../constructive/eo/schemes/zoo/CataM.scala | 9 + .../constructive/eo/schemes/zoo/Coattr.scala | 20 + .../{CitizensM.scala => zoo/FoldM.scala} | 48 +- .../{Decorations.scala => zoo/Gather.scala} | 92 +--- .../constructive/eo/schemes/zoo/Scatter.scala | 56 ++ .../eo/schemes/DecorationsSpec.scala | 1 + .../constructive/eo/schemes/FusionSpec.scala | 3 + .../eo/schemes/GatherScatterLawsSpec.scala | 1 + .../eo/schemes/SchemesConcurrencySpec.scala | 107 ++++ .../eo/schemes/SchemesMSpec.scala | 1 + .../constructive/eo/schemes/SchemesSpec.scala | 4 + .../eo/schemes/SchemesZooSpec.scala | 1 + site/docs/schemes.md | 7 +- .../eo/PlatedConcurrencySpec.scala | 70 +++ 21 files changed, 814 insertions(+), 563 deletions(-) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala rename schemes/src/main/scala/dev/constructive/eo/schemes/{Citizens.scala => zoo/Ana.scala} (53%) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/AnaM.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Attr.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/CataM.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coattr.scala rename schemes/src/main/scala/dev/constructive/eo/schemes/{CitizensM.scala => zoo/FoldM.scala} (57%) rename schemes/src/main/scala/dev/constructive/eo/schemes/{Decorations.scala => zoo/Gather.scala} (53%) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala create mode 100644 tests/src/test/scala/dev/constructive/eo/PlatedConcurrencySpec.scala 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 92077e80..89febce1 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 @@ -112,7 +112,7 @@ object SchemesFixtures: import higherkindness.droste.{CVAlgebra, CVCoalgebra, RAlgebra, RCoalgebra} import higherkindness.droste.data.{Attr => DAttr, Coattr => DCoattr} - import dev.constructive.eo.schemes.{Attr => EoAttr, Coattr => EoCoattr, Gather} + import dev.constructive.eo.schemes.zoo.{Attr => EoAttr, Coattr => EoCoattr, Gather} import dev.constructive.eo.data.BiAffine import dev.constructive.eo.optics.Optic 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..1f8b9479 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -0,0 +1,358 @@ +package dev.constructive.eo +package schemes + +import cats.{Monad, Traverse} + +/** Internal stack-safe fold engines for the typed recursion-scheme path. + * + * ==Thread-safety model== + * + * Every machine in this object allocates its mutable state (the frame deque, the `ret` variable, + * the per-node child/result arrays) **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: + * + * - [[EmptyAnyRefs]]: a zero-length `Array[AnyRef]`, shared by all leaf layers (see its own + * scaladoc for the immutability argument). + * - [[AscendToken]]: a stable identity object used as the "ascend" marker in [[foldLayeredM]]'s + * loop. It is never written and carries 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. + * + * Note that '''concurrent forcing of a single `M[R]` value''' is a different question (not the + * concurrency of independent `run(s)` calls) and remains unsupported: the mutable frame deque is + * allocated '''inside''' the `M` action, so two concurrent forces of the exact same suspended + * `M[R]` could interleave their tailRecM steps and corrupt each other's deques. 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 + + /** Shared zero-length children array for leaf nodes. + * + * '''What it is:''' a single `Array[AnyRef]` of length 0, allocated once and reused by every + * leaf layer encountered by [[childrenArr]], [[foldLayered]], [[foldLayeredOr]], and + * [[foldLayeredM]]. + * + * '''Who uses it:''' [[childrenArr]] returns this value whenever `F.size(fn) == 0` (a leaf + * layer — `LeafF`-like constructors with no recursive slots). Since leaf layers are a common + * case in typed pattern functors, the shared sentinel avoids a per-leaf empty-array allocation. + * + * '''Why it is thread-safe:''' the array has length 0. The store loops in all three engines + * (`foldLayered`, `foldLayeredOr`, `foldLayeredM`) are bounded by `arr.length`, so they + * execute zero iterations when `arr` is [[EmptyAnyRefs]]. No element is ever written into it. + * An immutable zero-length array is safe to share across any number of concurrent callers. + */ + private[schemes] val EmptyAnyRefs: Array[AnyRef] = new Array[AnyRef](0) + + /** Collect the children of typed layer `fn` into a flat `Array[AnyRef]`, single-pass via + * `ObjArrBuilder`. Returns [[EmptyAnyRefs]] for leaf layers (zero children) to avoid a per-leaf + * empty-array allocation. Used by [[foldLayered]], [[foldLayeredOr]], and [[foldLayeredM]] — one + * definition replaces the three identical nested `def childrenArr` that previously lived inside + * each engine. + * + * Leaf layers are a common case in typed pattern functors (every `LeafF`-like constructor + * carries no recursive slots), so the shared `EmptyAnyRefs` guard pays for itself. + */ + private[schemes] def childrenArr[F[_], N](fn: F[N])(using F: Traverse[F]): Array[AnyRef] = + val n = F.size(fn).toInt + if n == 0 then EmptyAnyRefs + else + val b = new data.ObjArrBuilder(n) + val _ = F.foldLeft(fn, ()) { (_, child) => + b.unsafeAppend(child.asInstanceOf[AnyRef]) + } + b.freezeArr + + /** Sentinel op for [[foldLayeredM]]'s loop state — "ascend": consume `ret` against the top frame. + * Anything else on the loop is the node to descend into. + */ + private[schemes] object AscendToken + + /** 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. + */ + private[schemes] def rebuildLayer[F[_], N, R](fn: F[N], out: Array[AnyRef])(using + F: Traverse[F] + ): F[R] = + if out.length == 0 then + fn.asInstanceOf[F[R]] // leaf: no N-slots, so F[N] is phantom-recast to F[R] + else + var i = -1 + F.map(fn) { _ => + i += 1 + out(i).asInstanceOf[R] + } + + /** [[rebuildLayer]]'s paramorphic sibling: pair each original child `N` with its folded result + * from `out` (positional, `Foldable` order). The subterms come from the layer the machine + * already holds — no re-`embed`. + */ + private[schemes] def rebuildLayerPaired[F[_], N, R](fn: F[N], out: Array[AnyRef])(using + F: Traverse[F] + ): F[(N, R)] = + if out.length == 0 then fn.asInstanceOf[F[(N, R)]] // leaf: no N-slots, phantom-recast + else + var i = -1 + F.map(fn) { n => + i += 1 + (n, out(i).asInstanceOf[R]) + } + + /** 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), then calls `combine` with the + * node, its layer `F[N]`, and the children's results (positional, `Foldable` order). `combine` + * rebuilds the typed `F[R]` via [[rebuildLayer]] and applies the user's algebra / embed. Same + * `< 512`-on-stack / heap-`ArrayDeque` hybrid (and stack-safety) as [[unfoldFold]] / + * [[foldInPlace]]; the per-node child array is reused as the result accumulator (folded in + * place). + */ + private[schemes] def foldLayered[F[_], N, R]( + expand: N => F[N], + combine: (N, F[N], Array[AnyRef]) => R, + )(using F: Traverse[F]): N => R = + + def heap(root: N): R = + final class Frame(val node: N, val layer: F[N], val arr: Array[AnyRef], var i: Int) + val stack = new java.util.ArrayDeque[Frame]() + var ret: AnyRef = null.asInstanceOf[AnyRef] + def enter(n: N): Unit = + val layer = expand(n) + val arr = childrenArr(layer) + if arr.length == 0 then ret = combine(n, layer, arr).asInstanceOf[AnyRef] + else stack.push(new Frame(n, layer, arr, 0)) + enter(root) + while !stack.isEmpty do + 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[N] + fr.i += 1 + enter(child) + else + ret = combine(fr.node, fr.layer, fr.arr).asInstanceOf[AnyRef] + val _ = stack.pop() + ret.asInstanceOf[R] + + def rec(n: N, depth: Int): R = + if depth >= OnStackLimit then heap(n) + else + val layer = expand(n) + val arr = childrenArr(layer) + val k = arr.length + if k == 0 then combine(n, layer, arr) + else + var i = 0 + while i < k do + arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] + i += 1 + combine(n, layer, arr) + + n => rec(n, 0) + + /** [[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 `< 512`-on-stack / + * heap-`ArrayDeque` hybrid and stack-safety as [[foldLayered]]. + */ + private[schemes] def foldLayeredOr[F[_], N, R]( + expandOr: N => Either[R, F[N]], + combine: (F[N], Array[AnyRef]) => R, + )(using F: Traverse[F]): N => R = + + def heap(root: N): R = + final class Frame(val layer: F[N], val arr: Array[AnyRef], var i: Int) + val stack = new java.util.ArrayDeque[Frame]() + var ret: AnyRef = null.asInstanceOf[AnyRef] + def enter(n: N): Unit = expandOr(n) match + case Left(r) => ret = r.asInstanceOf[AnyRef] // graft: finished, by reference + case Right(layer) => + val arr = childrenArr(layer) + if arr.length == 0 then ret = combine(layer, arr).asInstanceOf[AnyRef] + else stack.push(new Frame(layer, arr, 0)) + enter(root) + while !stack.isEmpty do + val fr = stack.peek() + if fr.i > 0 then fr.arr(fr.i - 1) = ret + if fr.i < fr.arr.length then + val child = fr.arr(fr.i).asInstanceOf[N] + fr.i += 1 + enter(child) + else + ret = combine(fr.layer, fr.arr).asInstanceOf[AnyRef] + val _ = stack.pop() + ret.asInstanceOf[R] + + def rec(n: N, depth: Int): R = + if depth >= OnStackLimit then heap(n) + else + expandOr(n) match + case Left(r) => r // graft: finished, by reference + case Right(layer) => + val arr = childrenArr(layer) + val k = arr.length + if k == 0 then combine(layer, arr) + else + var i = 0 + while i < k do + arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] + i += 1 + combine(layer, arr) + + n => rec(n, 0) + + // =========================================================================================== + // The M-generic path — the foldLayered state machine LIFTED into a Monad[M] (no M = Id + // special-case: that is what makes the fast-path agreement laws 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). 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 machine'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. One `M`-action per `tailRecM` iteration: `Down(n)` runs `expandOr`, exits + * run `combine`; the mutable frame deque is allocated per-force (inside the `M`) so re-forcing + * the same `M[R]` value allocates fresh state. Concurrent forcing of a single `M[R]` value + * remains unsupported (mutable state, linear-M contract); each `run(s)` call is independent. + */ + private[schemes] def foldLayeredM[M[_], F[_], N, R]( + expandOr: N => M[Either[R, F[N]]], + combine: (N, F[N], Array[AnyRef]) => M[R], + )(using M: Monad[M], F: Traverse[F]): N => M[R] = + + final class Frame(val node: N, val layer: F[N], val arr: Array[AnyRef], var i: Int) + + n0 => + M.flatMap(M.unit) { _ => + val stack = new java.util.ArrayDeque[Frame]() + var ret: AnyRef = null.asInstanceOf[AnyRef] + // Op encoding (allocation-lean — CI 2026-06-12: per-event Either allocation + // dominated the M path's 1.6M B/op): the loop state is a bare AnyRef — the + // AscendToken sentinel means "consume ret against the top frame", anything else + // is the node to descend into. One Left per descend (vs nested Left(Right(n))); + // the ascend step is the hoisted constant. + val ascend: Either[AnyRef, R] = Left(AscendToken) + M.tailRecM[AnyRef, R](n0.asInstanceOf[AnyRef]) { op => + if op.asInstanceOf[AnyRef] ne AscendToken then + val n = op.asInstanceOf[N] + M.flatMap(expandOr(n)) { + case Left(r) => // graft/short-circuit arm (unused by v1 drivers) + ret = r.asInstanceOf[AnyRef] + M.pure(ascend) + case Right(layer) => + val arr = childrenArr(layer)(using F) + if arr.length == 0 then + // leaf: combine INLINE (a constant second bind — no frame, no extra event) + M.map(combine(n, layer, arr)) { r => + ret = r.asInstanceOf[AnyRef] + ascend + } + else + stack.push(new Frame(n, layer, arr, 0)) + M.pure(Left(arr(0))) + } + else if stack.isEmpty then M.pure(Right(ret.asInstanceOf[R])) + else + val fr = stack.peek() + fr.arr(fr.i) = ret // store the just-folded child's result + fr.i += 1 + if fr.i < fr.arr.length then M.pure(Left(fr.arr(fr.i))) + else + // last child stored: combine NOW (merged — no intermediate pure event) + M.map(combine(fr.node, fr.layer, fr.arr)) { r => + val _ = stack.pop() + ret = r.asInstanceOf[AnyRef] + ascend + } + } + } + + /** Single-pass paired machine in `M` backing the fused `AnaM.andThen(CataM)` — the M mirror of + * [[fusedPairedFold]]: each node built once, folded immediately, no `M[S]` whole-structure + * materialization. Mirrors the pure version exactly: `F[S]` and `F[A]` are built straight from + * the out-array with two `F.map(fSeed)` passes and `var i = -1` counters, avoiding the + * `F[(S,A)]` intermediate. Leaf layers are phantom-recast (valid because pattern-functor leaves + * have no recursive slots by definition). + */ + private[schemes] def fusedPairedFoldM[M[_], F[_], Seed, S, A]( + coalgM: Seed => M[F[Seed]], + algM: (S, F[A]) => M[A], + )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): Seed => M[(S, A)] = + foldLayeredM[M, F, Seed, (S, A)]( + seed => M.map(coalgM(seed))(Right(_)), + (_, fSeed, out) => + // Build F[S] and F[A] straight from the out-array — no F[(S,A)] intermediate. + val fS = + if out.length == 0 then fSeed.asInstanceOf[F[S]] + else + var i = -1 + F.map(fSeed) { _ => + i += 1 + out(i).asInstanceOf[(S, A)]._1 + } + val fA = + if out.length == 0 then fSeed.asInstanceOf[F[A]] + else + var i = -1 + F.map(fSeed) { _ => + i += 1 + out(i).asInstanceOf[(S, A)]._2 + } + val s = E.embed(fS) + M.map(algM(s, fA))(a => (s, a)), + ) + + /** The single-pass paired machine backing the fused `Ana.cross(Cata)`: each node is built once + * (the algebra is node-supplied — construction is semantically required), folded immediately, + * and released as the fold ascends. No full-tree retention, no second traversal. Leaf layers are + * phantom-recast (valid because pattern-functor leaves have no recursive slots by definition). + */ + private[schemes] def fusedPairedFold[F[_], Seed, S, A]( + coalg: Seed => F[Seed], + alg: (S, F[A]) => A, + )(using F: Traverse[F], E: Embed[F, S]): Seed => (S, A) = + foldLayered[F, Seed, (S, A)]( + coalg, + (_, fSeed, out) => + // Build F[S] and F[A] straight from the out-array — no F[(S, A)] intermediate + // (CI 2026-06-12: that third F-alloc per node put the fused cross ABOVE the + // materializing composition in B/op, 1049k vs 886k). + val fS = + if out.length == 0 then fSeed.asInstanceOf[F[S]] + else + var i = -1 + F.map(fSeed) { _ => + i += 1 + out(i).asInstanceOf[(S, A)]._1 + } + val fA = + if out.length == 0 then fSeed.asInstanceOf[F[A]] + else + var i = -1 + F.map(fSeed) { _ => + i += 1 + out(i).asInstanceOf[(S, A)]._2 + } + val s = E.embed(fS) + (s, alg(s, fA)), + ) 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 6baec97f..4492059b 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -5,6 +5,7 @@ import cats.{Monad, Traverse} import data.{Forget, ForgetK} import optics.{Getter, Optic, Review} +import zoo.* /** 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` — @@ -22,348 +23,11 @@ import optics.{Getter, Optic, Review} * `Monad[M].tailRecM` (effectful layers, single-pass linear Ms). * * All drivers run on one stack-safe engine family: a `< 512`-deep on-stack fast path falling back - * per deep subtree to a heap `ArrayDeque` machine ([[foldLayered]] and siblings) — stack-safe to - * 10⁶, tested. + * per deep subtree to a heap `ArrayDeque` machine ([[Machines.foldLayered]] and siblings) — + * stack-safe to 10⁶, tested. */ object Schemes: - /** 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 - - /** Shared zero-length children array for leaf nodes — avoids a per-leaf empty-array allocation in - * the typed [[foldLayered]] machine. - */ - private val EmptyAnyRefs: Array[AnyRef] = new Array[AnyRef](0) - - /** Collect the children of typed layer `fn` into a flat `Array[AnyRef]`, single-pass via - * `ObjArrBuilder`. Returns [[EmptyAnyRefs]] for leaf layers (zero children) to avoid a per-leaf - * empty-array allocation. Used by [[foldLayered]], [[foldLayeredOr]], and [[foldLayeredM]] — one - * definition replaces the three identical nested `def childrenArr` that previously lived inside - * each engine. - * - * Leaf layers are a common case in typed pattern functors (every `LeafF`-like constructor - * carries no recursive slots), so the shared `EmptyAnyRefs` guard pays for itself. - */ - private def childrenArr[F[_], N](fn: F[N])(using F: Traverse[F]): Array[AnyRef] = - val n = F.size(fn).toInt - if n == 0 then EmptyAnyRefs - else - val b = new data.ObjArrBuilder(n) - val _ = F.foldLeft(fn, ()) { (_, child) => - b.unsafeAppend(child.asInstanceOf[AnyRef]) - } - b.freezeArr - - /** Sentinel op for [[foldLayeredM]]'s loop state — "ascend": consume `ret` against the top frame. - * Anything else on the loop is the node to descend into. - */ - private object AscendToken - - // =========================================================================================== - // Typed pattern-functor path — the opt-in, type-safe complement to the PSVec schemes above. - // - // The user supplies a pattern functor `F[_]` + its `Traverse[F]`, and (for cata/ana) - // `Project[F, S]` / `Embed[F, S]`. Algebras pattern-match `F`'s NAMED constructors - // (`case BranchF(l, r) => l + r`) — no `PSVec[AnyRef]`, no positional indexing. See [[Basis]]. - // - // These run on the SAME `< 512`-on-stack / heap-`ArrayDeque` hybrid as the PSVec schemes above - // (see [[foldLayered]]) — NOT a `cats.Eval` trampoline. The deep recursion is driven by the - // machine; the user's `Traverse[F]` is used only per *layer* (bounded fanout: `foldLeft` to read a - // node's children, `map` to rebuild the typed `F[result]` for the algebra), never across the - // spine. So stack-safety needs no `Eval`-lazy `foldRight` from the user — any lawful `Traverse[F]` - // works — and allocation is close to the PSVec path (one children/result array + the typed `F` - // layers per node), not the ~Eval-node-per-layer a trampoline would cost. - // =========================================================================================== - - /** 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. - */ - private def rebuildLayer[F[_], N, R](fn: F[N], out: Array[AnyRef])(using F: Traverse[F]): F[R] = - if out.length == 0 then - fn.asInstanceOf[F[R]] // leaf: no N-slots, so F[N] is phantom-recast to F[R] - else - var i = -1 - F.map(fn) { _ => - i += 1 - out(i).asInstanceOf[R] - } - - /** [[rebuildLayer]]'s paramorphic sibling: pair each original child `N` with its folded result - * from `out` (positional, `Foldable` order). The subterms come from the layer the machine - * already holds — no re-`embed`. - */ - private def rebuildLayerPaired[F[_], N, R](fn: F[N], out: Array[AnyRef])(using - F: Traverse[F] - ): F[(N, R)] = - if out.length == 0 then fn.asInstanceOf[F[(N, R)]] // leaf: no N-slots, phantom-recast - else - var i = -1 - F.map(fn) { n => - i += 1 - (n, out(i).asInstanceOf[R]) - } - - /** 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), then calls `combine` with the - * node, its layer `F[N]`, and the children's results (positional, `Foldable` order). `combine` - * rebuilds the typed `F[R]` via [[rebuildLayer]] and applies the user's algebra / embed. Same - * `< 512`-on-stack / heap-`ArrayDeque` hybrid (and stack-safety) as [[unfoldFold]] / - * [[foldInPlace]]; the per-node child array is reused as the result accumulator (folded in - * place). - */ - private def foldLayered[F[_], N, R]( - expand: N => F[N], - combine: (N, F[N], Array[AnyRef]) => R, - )(using F: Traverse[F]): N => R = - - def heap(root: N): R = - final class Frame(val node: N, val layer: F[N], val arr: Array[AnyRef], var i: Int) - val stack = new java.util.ArrayDeque[Frame]() - var ret: AnyRef = null.asInstanceOf[AnyRef] - def enter(n: N): Unit = - val layer = expand(n) - val arr = childrenArr(layer) - if arr.length == 0 then ret = combine(n, layer, arr).asInstanceOf[AnyRef] - else stack.push(new Frame(n, layer, arr, 0)) - enter(root) - while !stack.isEmpty do - 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[N] - fr.i += 1 - enter(child) - else - ret = combine(fr.node, fr.layer, fr.arr).asInstanceOf[AnyRef] - val _ = stack.pop() - ret.asInstanceOf[R] - - def rec(n: N, depth: Int): R = - if depth >= OnStackLimit then heap(n) - else - val layer = expand(n) - val arr = childrenArr(layer) - val k = arr.length - if k == 0 then combine(n, layer, arr) - else - var i = 0 - while i < k do - arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] - i += 1 - combine(n, layer, arr) - - n => rec(n, 0) - - /** [[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 `< 512`-on-stack / - * heap-`ArrayDeque` hybrid and stack-safety as [[foldLayered]]. - */ - private def foldLayeredOr[F[_], N, R]( - expandOr: N => Either[R, F[N]], - combine: (F[N], Array[AnyRef]) => R, - )(using F: Traverse[F]): N => R = - - def heap(root: N): R = - final class Frame(val layer: F[N], val arr: Array[AnyRef], var i: Int) - val stack = new java.util.ArrayDeque[Frame]() - var ret: AnyRef = null.asInstanceOf[AnyRef] - def enter(n: N): Unit = expandOr(n) match - case Left(r) => ret = r.asInstanceOf[AnyRef] // graft: finished, by reference - case Right(layer) => - val arr = childrenArr(layer) - if arr.length == 0 then ret = combine(layer, arr).asInstanceOf[AnyRef] - else stack.push(new Frame(layer, arr, 0)) - enter(root) - while !stack.isEmpty do - val fr = stack.peek() - if fr.i > 0 then fr.arr(fr.i - 1) = ret - if fr.i < fr.arr.length then - val child = fr.arr(fr.i).asInstanceOf[N] - fr.i += 1 - enter(child) - else - ret = combine(fr.layer, fr.arr).asInstanceOf[AnyRef] - val _ = stack.pop() - ret.asInstanceOf[R] - - def rec(n: N, depth: Int): R = - if depth >= OnStackLimit then heap(n) - else - expandOr(n) match - case Left(r) => r // graft: finished, by reference - case Right(layer) => - val arr = childrenArr(layer) - val k = arr.length - if k == 0 then combine(layer, arr) - else - var i = 0 - while i < k do - arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] - i += 1 - combine(layer, arr) - - n => rec(n, 0) - - // =========================================================================================== - // The M-generic path — the foldLayered state machine LIFTED into a Monad[M] (no M = Id - // special-case: that is what makes the fast-path agreement laws 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). 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 machine'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. One `M`-action per `tailRecM` iteration: `Down(n)` runs `expandOr`, exits - * run `combine`; the mutable frame deque is allocated per-force (inside the `M`) so re-forcing - * the same `M[R]` value allocates fresh state. Concurrent forcing of a single `M[R]` value - * remains unsupported (mutable state, linear-M contract); each `run(s)` call is independent. - */ - private def foldLayeredM[M[_], F[_], N, R]( - expandOr: N => M[Either[R, F[N]]], - combine: (N, F[N], Array[AnyRef]) => M[R], - )(using M: Monad[M], F: Traverse[F]): N => M[R] = - - final class Frame(val node: N, val layer: F[N], val arr: Array[AnyRef], var i: Int) - - n0 => - M.flatMap(M.unit) { _ => - val stack = new java.util.ArrayDeque[Frame]() - var ret: AnyRef = null.asInstanceOf[AnyRef] - // Op encoding (allocation-lean — CI 2026-06-12: per-event Either allocation - // dominated the M path's 1.6M B/op): the loop state is a bare AnyRef — the - // AscendToken sentinel means "consume ret against the top frame", anything else - // is the node to descend into. One Left per descend (vs nested Left(Right(n))); - // the ascend step is the hoisted constant. - val ascend: Either[AnyRef, R] = Left(AscendToken) - M.tailRecM[AnyRef, R](n0.asInstanceOf[AnyRef]) { op => - if op.asInstanceOf[AnyRef] ne AscendToken then - val n = op.asInstanceOf[N] - M.flatMap(expandOr(n)) { - case Left(r) => // graft/short-circuit arm (unused by v1 drivers) - ret = r.asInstanceOf[AnyRef] - M.pure(ascend) - case Right(layer) => - val arr = childrenArr(layer)(using F) - if arr.length == 0 then - // leaf: combine INLINE (a constant second bind — no frame, no extra event) - M.map(combine(n, layer, arr)) { r => - ret = r.asInstanceOf[AnyRef] - ascend - } - else - stack.push(new Frame(n, layer, arr, 0)) - M.pure(Left(arr(0))) - } - else if stack.isEmpty then M.pure(Right(ret.asInstanceOf[R])) - else - val fr = stack.peek() - fr.arr(fr.i) = ret // store the just-folded child's result - fr.i += 1 - if fr.i < fr.arr.length then M.pure(Left(fr.arr(fr.i))) - else - // last child stored: combine NOW (merged — no intermediate pure event) - M.map(combine(fr.node, fr.layer, fr.arr)) { r => - val _ = stack.pop() - ret = r.asInstanceOf[AnyRef] - ascend - } - } - } - - /** Effectful catamorphism — the algebra runs in `M` (`(S, F[A]) => M[A]`); the layer peel stays - * the pure `Project`. Returns the `Forget[M]`-carried [[CataM]] citizen; consume via `.run`. - */ - def cataM[M[_], F[_], S, A]( - algM: (S, F[A]) => M[A] - )(using M: Monad[M], F: Traverse[F], P: Project[F, S]): CataM[M, F, S, A] = - new CataM[M, F, S, A]( - foldLayeredM[M, F, S, A]( - s => M.pure(Right(P.project(s))), - (s, fs, out) => algM(s, rebuildLayer[F, S, A](fs, out)), - ), - algM, - ) - - /** Effectful anamorphism — the coalgebra (the layer producer) runs in `M` (`Seed => M[F[Seed]]`, - * the arbo `GetSellOptions` shape: fetching children is effectful). Returns the [[AnaM]] - * citizen; consume via `.run`, fuse via `.andThen(cataM(...))`. - */ - def anaM[M[_], F[_], Seed, S]( - coalgM: Seed => M[F[Seed]] - )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): AnaM[M, F, Seed, S] = - new AnaM[M, F, Seed, S]( - foldLayeredM[M, F, Seed, S]( - seed => M.map(coalgM(seed))(Right(_)), - (_, fSeed, out) => M.pure(E.embed(rebuildLayer[F, Seed, S](fSeed, out))), - ), - coalgM, - ) - - /** Effectful hylomorphism — the always-fused M spelling (what the D6 `eoHyloM` bench row runs): - * `Seed => M[A]` with **no intermediate `S`**, seed-typed algebra. - */ - def hyloM[M[_], F[_], Seed, A]( - coalgM: Seed => M[F[Seed]], - algM: (Seed, F[A]) => M[A], - )(using M: Monad[M], F: Traverse[F]): FoldM[M, Seed, A] = - new FoldM[M, Seed, A]( - foldLayeredM[M, F, Seed, A]( - seed => M.map(coalgM(seed))(Right(_)), - (seed, fSeed, out) => algM(seed, rebuildLayer[F, Seed, A](fSeed, out)), - ) - ) - - /** Single-pass paired machine in `M` backing the fused `AnaM.andThen(CataM)` — the M mirror of - * [[fusedPairedFold]]: each node built once, folded immediately, no `M[S]` whole-structure - * materialization. Mirrors the pure version exactly: `F[S]` and `F[A]` are built straight from - * the out-array with two `F.map(fSeed)` passes and `var i = -1` counters, avoiding the - * `F[(S,A)]` intermediate. Leaf layers are phantom-recast (valid because pattern-functor leaves - * have no recursive slots by definition). - */ - private[schemes] def fusedPairedFoldM[M[_], F[_], Seed, S, A]( - coalgM: Seed => M[F[Seed]], - algM: (S, F[A]) => M[A], - )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): Seed => M[(S, A)] = - foldLayeredM[M, F, Seed, (S, A)]( - seed => M.map(coalgM(seed))(Right(_)), - (_, fSeed, out) => - // Build F[S] and F[A] straight from the out-array — no F[(S,A)] intermediate. - val fS = - if out.length == 0 then fSeed.asInstanceOf[F[S]] - else - var i = -1 - F.map(fSeed) { _ => - i += 1 - out(i).asInstanceOf[(S, A)]._1 - } - val fA = - if out.length == 0 then fSeed.asInstanceOf[F[A]] - else - var i = -1 - F.map(fSeed) { _ => - i += 1 - out(i).asInstanceOf[(S, A)]._2 - } - val s = E.embed(fS) - M.map(algM(s, fA))(a => (s, a)), - ) - /** The single *layer* optic for a pattern functor `F`: `project`/`embed` worn as the existing * [[dev.constructive.eo.data.Forget]] carrier. `to = project: S => F[S]`, `from = embed: F[S] => * S`, so it is a genuine `Optic[S, S, S, S, Forget[F]]` with **no change to the `Optic` trait**. @@ -384,9 +48,10 @@ object Schemes: /** Catamorphism over a typed pattern functor `F`, as a composable `Getter`. `alg` sees the * original node `S` (paramorphism-flavored) plus its already-folded children as a typed `F[A]`. - * Pure `F[A] => A` folds ignore the `S`. Stack-safe to arbitrary depth (the [[foldLayered]] - * machine, not a trampoline). Requires `Project[F, S]` (to peel each layer) and `Traverse[F]` - * (any lawful instance — the machine, not the user's `foldRight`, provides stack-safety). + * Pure `F[A] => A` folds ignore the `S`. Stack-safe to arbitrary depth (the + * [[Machines.foldLayered]] machine, not a trampoline). Requires `Project[F, S]` (to peel each + * layer) and `Traverse[F]` (any lawful instance — the machine, not the user's `foldRight`, + * provides stack-safety). */ def cata[F[_], S, A]( alg: (S, F[A]) => A @@ -411,13 +76,16 @@ object Schemes: // W =:= A by construction of the singleton — the direct engine path, no decoration cost. val alg = galg.asInstanceOf[(S, F[A]) => A] Getter[S, A]( - foldLayered[F, S, A](P.project, (s, fs, out) => alg(s, rebuildLayer[F, S, A](fs, out))) + Machines.foldLayered[F, S, A]( + P.project, + (s, fs, out) => alg(s, Machines.rebuildLayer[F, S, A](fs, out)), + ) ) else - val toW: S => W = foldLayered[F, S, W]( + val toW: S => W = Machines.foldLayered[F, S, W]( P.project, (s, fs, out) => - val fw = rebuildLayer[F, S, W](fs, out) + val fw = Machines.rebuildLayer[F, S, W](fs, out) decor.from(new data.BiAffine.Step[(Unit, F[W]), A](fw, galg(s, fw))), ) Getter[S, A] { s => @@ -429,15 +97,15 @@ object Schemes: * with its folded result. Native route: the machine already walks real `S` nodes and keeps each * frame's projected layer, so subterms are paired positionally — no per-node re-`embed` * (droste's `Gather.para` must reconstruct the subterm it threw away). Stack-safe (the - * [[foldLayered]] machine). + * [[Machines.foldLayered]] machine). */ def para[F[_], S, A]( alg: (S, F[(S, A)]) => A )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = Getter[S, A]( - foldLayered[F, S, A]( + Machines.foldLayered[F, S, A]( P.project, - (s, fs, out) => alg(s, rebuildLayerPaired[F, S, A](fs, out)), + (s, fs, out) => alg(s, Machines.rebuildLayerPaired[F, S, A](fs, out)), ) ) @@ -455,76 +123,41 @@ object Schemes: def histo[F[_], S, A]( alg: (S, F[Attr[F, A]]) => A )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = - val toAttr: S => Attr[F, A] = foldLayered[F, S, Attr[F, A]]( + val toAttr: S => Attr[F, A] = Machines.foldLayered[F, S, Attr[F, A]]( P.project, (s, fs, out) => - val layer = rebuildLayer[F, S, Attr[F, A]](fs, out) + val layer = Machines.rebuildLayer[F, S, Attr[F, A]](fs, out) Attr(alg(s, layer), layer), ) Getter[S, A](s => toAttr(s).head) /** Anamorphism over a typed pattern functor `F`, as a `Review`. The single fused coalgebra `Seed * => F[Seed]` yields one typed layer of child seeds; [[Embed]] assembles each layer into the - * built `S`. Materializing — the built `S` is O(nodes). Stack-safe (the [[foldLayered]] machine). - * Requires `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input before output) - * to match [[hylo]] and the `PSVec` [[ana]]. + * built `S`. Materializing — the built `S` is O(nodes). Stack-safe (the [[Machines.foldLayered]] + * machine). Requires `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input + * before output) to match [[hylo]] and the `PSVec` [[ana]]. */ def ana[F[_], Seed, S]( coalg: Seed => F[Seed] )(using F: Traverse[F], E: Embed[F, S]): Ana[F, Seed, S] = new Ana[F, Seed, S](ana[F, Seed, Seed, S](Scatter.ana[F, Seed])(coalg).reverseGet, coalg) - /** Single-pass paired machine backing the fused `Ana.cross(Cata)`: each node is built once (the - * algebra is node-supplied — construction is semantically required), folded immediately, and - * released as the fold ascends. No full-tree retention, no second traversal. Leaf layers are - * phantom-recast (valid because pattern-functor leaves have no recursive slots by definition). - */ - private[schemes] def fusedPairedFold[F[_], Seed, S, A]( - coalg: Seed => F[Seed], - alg: (S, F[A]) => A, - )(using F: Traverse[F], E: Embed[F, S]): Seed => (S, A) = - foldLayered[F, Seed, (S, A)]( - coalg, - (_, fSeed, out) => - // Build F[S] and F[A] straight from the out-array — no F[(S, A)] intermediate - // (CI 2026-06-12: that third F-alloc per node put the fused cross ABOVE the - // materializing composition in B/op, 1049k vs 886k). - val fS = - if out.length == 0 then fSeed.asInstanceOf[F[S]] - else - var i = -1 - F.map(fSeed) { _ => - i += 1 - out(i).asInstanceOf[(S, A)]._1 - } - val fA = - if out.length == 0 then fSeed.asInstanceOf[F[A]] - else - var i = -1 - F.map(fSeed) { _ => - i += 1 - out(i).asInstanceOf[(S, A)]._2 - } - val s = E.embed(fS) - (s, alg(s, fA)), - ) - /** Apomorphism over a typed pattern functor `F` — per child slot the coalgebra answers * `Right(seed)` (keep unfolding) or `Left(s)` (an **already-finished subtree**). Native O(1) * graft: `Left` subtrees are prefilled into their result slots **by reference** — never - * recursed, never projected ([[foldLayeredOr]]). Contrast droste's scatter-apo, which re-walks - * grafts through `project` (O(graft) per graft — the distApo route, kept only as a law fixture - * in the test suite). Stack-safe. + * recursed, never projected ([[Machines.foldLayeredOr]]). Contrast droste's scatter-apo, which + * re-walks grafts through `project` (O(graft) per graft — the distApo route, kept only as a law + * fixture in the test suite). Stack-safe. */ def apo[F[_], A, S]( coalg: A => F[Either[S, A]] )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = - val run = foldLayeredOr[F, Either[S, A], S]( + val run = Machines.foldLayeredOr[F, Either[S, A], S]( { case Left(s) => Left(s) case Right(a) => Right(coalg(a)) }, - (fw, out) => E.embed(rebuildLayer[F, Either[S, A], S](fw, out)), + (fw, out) => E.embed(Machines.rebuildLayer[F, Either[S, A], S](fw, out)), ) Review[S, A](a => run(Right(a))) @@ -543,9 +176,9 @@ object Schemes: val expand: Coattr[F, A] => F[Coattr[F, A]] = case Coattr.Pure(a) => coalg(a) case Coattr.Roll(layer) => layer - val build = foldLayered[F, Coattr[F, A], S]( + val build = Machines.foldLayered[F, Coattr[F, A], S]( expand, - (_, fw, out) => E.embed(rebuildLayer[F, Coattr[F, A], S](fw, out)), + (_, fw, out) => E.embed(Machines.rebuildLayer[F, Coattr[F, A], S](fw, out)), ) Review[S, A](a => build(Coattr.Pure(a))) @@ -569,24 +202,27 @@ object Schemes: // W =:= A by construction of the singleton — the direct engine path. val coalg = gcoalg.asInstanceOf[A => F[A]] Review[S, A]( - foldLayered[F, A, S](coalg, (_, fSeed, out) => E.embed(rebuildLayer[F, A, S](fSeed, out))) + Machines.foldLayered[F, A, S]( + coalg, + (_, fSeed, out) => E.embed(Machines.rebuildLayer[F, A, S](fSeed, out)), + ) ) else val expand: W => F[W] = w => decor.to(w) match case st: data.BiAffine.Step[(F[W], Unit), A] => gcoalg(st.b) case dn: data.BiAffine.Done[(F[W], Unit), A] => dn.fst - val build: W => S = foldLayered[F, W, S]( + val build: W => S = Machines.foldLayered[F, W, S]( expand, - (_, fw, out) => E.embed(rebuildLayer[F, W, S](fw, out)), + (_, fw, out) => E.embed(Machines.rebuildLayer[F, W, S](fw, out)), ) Review[S, A](a => build(decor.from(new data.BiAffine.Step[(F[W], Unit), A]((), a)))) /** Hylomorphism over a typed pattern functor `F` — the **fused** refold `Seed => A`, building * **no intermediate `S`** (so it needs neither `Project` nor `Embed`, only `Traverse[F]`). * `coalg` unfolds a seed into one typed layer; `alg` folds the layer's results to `A` (the seed - * is supplied, paramorphism-flavored). Stack-safe (the [[foldLayered]] machine). Equal to - * `ana(coalg).cross(cata(alg))` for a *pure* algebra (the hylo law); for a node-reading para + * is supplied, paramorphism-flavored). Stack-safe (the [[Machines.foldLayered]] machine). Equal + * to `ana(coalg).cross(cata(alg))` for a *pure* algebra (the hylo law); for a node-reading para * algebra the two agree only under the seed↔`embed(coalg(seed))` correspondence. */ def hylo[F[_], Seed, A]( @@ -594,8 +230,51 @@ object Schemes: alg: (Seed, F[A]) => A, )(using F: Traverse[F]): Getter[Seed, A] = Getter[Seed, A]( - foldLayered[F, Seed, A]( + Machines.foldLayered[F, Seed, A]( coalg, - (seed, fSeed, out) => alg(seed, rebuildLayer[F, Seed, A](fSeed, out)), + (seed, fSeed, out) => alg(seed, Machines.rebuildLayer[F, Seed, A](fSeed, out)), + ) + ) + + /** Effectful catamorphism — the algebra runs in `M` (`(S, F[A]) => M[A]`); the layer peel stays + * the pure `Project`. Returns the `Forget[M]`-carried [[CataM]] citizen; consume via `.run`. + */ + def cataM[M[_], F[_], S, A]( + algM: (S, F[A]) => M[A] + )(using M: Monad[M], F: Traverse[F], P: Project[F, S]): CataM[M, F, S, A] = + new CataM[M, F, S, A]( + Machines.foldLayeredM[M, F, S, A]( + s => M.pure(Right(P.project(s))), + (s, fs, out) => algM(s, Machines.rebuildLayer[F, S, A](fs, out)), + ), + algM, + ) + + /** Effectful anamorphism — the coalgebra (the layer producer) runs in `M` (`Seed => M[F[Seed]]`, + * the arbo `GetSellOptions` shape: fetching children is effectful). Returns the [[AnaM]] + * citizen; consume via `.run`, fuse via `.andThen(cataM(...))`. + */ + def anaM[M[_], F[_], Seed, S]( + coalgM: Seed => M[F[Seed]] + )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): AnaM[M, F, Seed, S] = + new AnaM[M, F, Seed, S]( + Machines.foldLayeredM[M, F, Seed, S]( + seed => M.map(coalgM(seed))(Right(_)), + (_, fSeed, out) => M.pure(E.embed(Machines.rebuildLayer[F, Seed, S](fSeed, out))), + ), + coalgM, + ) + + /** Effectful hylomorphism — the always-fused M spelling (what the D6 `eoHyloM` bench row runs): + * `Seed => M[A]` with **no intermediate `S`**, seed-typed algebra. + */ + def hyloM[M[_], F[_], Seed, A]( + coalgM: Seed => M[F[Seed]], + algM: (Seed, F[A]) => M[A], + )(using M: Monad[M], F: Traverse[F]): FoldM[M, Seed, A] = + new FoldM[M, Seed, A]( + Machines.foldLayeredM[M, F, Seed, A]( + seed => M.map(coalgM(seed))(Right(_)), + (seed, fSeed, out) => algM(seed, Machines.rebuildLayer[F, Seed, A](fSeed, out)), ) ) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala similarity index 53% rename from schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala rename to schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala index 00a0af34..ae41bfa1 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Citizens.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala @@ -1,41 +1,16 @@ package dev.constructive.eo package schemes +package zoo import cats.Traverse import data.Direct import optics.{Getter, Optic} -/** Concrete scheme citizens — `cata`/`ana` return these instead of bare `Getter`/`Review` so - * composition can **fuse**: the classes carry their (co)algebra and instances as data, and the - * fused `cross` overload below resolves on the concrete types. +/** Unfold-scheme citizen: Review-shaped, carrying the coalgebra + instances for fusion. * - * They extend the open `Optic` trait directly (`Getter`/`Review` are `final` in core — the - * perf-pinned encoding stays untouched): full generic composition via the trait members, plus - * `.get` / `.reverseGet` as stored fields, the use-site-friendly shape. - * - * Widening hazard, documented: binding an `Ana` to a wider type (`Review`-shaped `Optic`) loses - * the fused `cross` overload — the generic trait `cross` still typechecks and is extensionally - * equal, but materializes the full intermediate structure. `Schemes.hylo(coalg, alg)` stays the - * always-fused spelling. + * See [[Cata]] for the general citizen contract (widening hazard, encoding rationale). */ - -/** Fold-scheme citizen: Getter-shaped, carrying the node-supplied algebra for fusion. */ -final class Cata[F[_], S, A] private[schemes] ( - val get: S => A, - private[schemes] val alg: (S, F[A]) => A, -) extends Optic[S, Unit, A, Unit, Direct]: - type X = Nothing - - def to(s: S): Direct[X, A] = Direct(get(s)) - def from(d: Direct[X, Unit]): Unit = () - - /** View as a plain [[Getter]] — re-enters Getter's fused composition fast paths (and resolves the - * read-compose overload tie an unascribed `getter.andThen(cata(...))` can hit). - */ - def asGetter: Getter[S, A] = Getter(get) - -/** Unfold-scheme citizen: Review-shaped, carrying the coalgebra + instances for fusion. */ final class Ana[F[_], Seed, S] private[schemes] ( val reverseGet: Seed => S, private[schemes] val coalg: Seed => F[Seed], @@ -65,5 +40,5 @@ final class Ana[F[_], Seed, S] private[schemes] ( * to the generic, materializing route — extensionally equal, allocation-different. */ def cross[A](inner: Cata[F, S, A]): Getter[Seed, A] = - val machine: Seed => (S, A) = Schemes.fusedPairedFold(coalg, inner.alg)(using F, E) + val machine: Seed => (S, A) = Machines.fusedPairedFold(coalg, inner.alg)(using F, E) Getter[Seed, A](seed => machine(seed)._2) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/AnaM.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/AnaM.scala new file mode 100644 index 00000000..7947a2b3 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/AnaM.scala @@ -0,0 +1,27 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.{Monad, Traverse} + +/** Effectful unfold-scheme citizen: `run: Seed => M[S]`, carrying the coalgebra + instances for + * fusion. + */ +final class AnaM[M[_], F[_], Seed, S] private[schemes] ( + run: Seed => M[S], + private[schemes] val coalgM: Seed => M[F[Seed]], +)(using + private[schemes] val M: Monad[M], + private[schemes] val F: Traverse[F], + private[schemes] val E: Embed[F, S], +) extends FoldM[M, Seed, S](run): + + /** The fused M seam — here `andThen` genuinely is the focus seam (`Forget[M]` Kleisli). One + * single-pass machine in `M` (the paired fold lifted through `tailRecM`): each node built once, + * folded immediately — no `M[S]` materialization of the whole structure. Requires the concrete + * types; widened operands fall back to the generic materializing `andThen`. + */ + def andThen[A](inner: CataM[M, F, S, A]): FoldM[M, Seed, A] = + val machine: Seed => M[(S, A)] = + Machines.fusedPairedFoldM(coalgM, inner.algM)(using M, F, E) + new FoldM[M, Seed, A](seed => M.map(machine(seed))(_._2)) 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..d1176dc3 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Attr.scala @@ -0,0 +1,19 @@ +package dev.constructive.eo +package schemes +package zoo + +/** 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 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..eb96f46d --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala @@ -0,0 +1,35 @@ +package dev.constructive.eo +package schemes +package zoo + +import data.Direct +import optics.{Getter, Optic} + +/** Fold-scheme citizen: Getter-shaped, carrying the node-supplied algebra for fusion. + * + * Concrete scheme citizens — `cata`/`ana` return these instead of bare `Getter`/`Review` so + * composition can **fuse**: the classes carry their (co)algebra and instances as data, and the + * fused `cross` overload on [[Ana]] resolves on the concrete types. + * + * They extend the open `Optic` trait directly (`Getter`/`Review` are `final` in core — the + * perf-pinned encoding stays untouched): full generic composition via the trait members, plus + * `.get` / `.reverseGet` as stored fields, the use-site-friendly shape. + * + * Widening hazard, documented: binding a `Cata` (or `Ana`) to a wider type loses the fused + * `cross` overload — the generic trait `cross` still typechecks and is extensionally equal, but + * materializes the full intermediate structure. `Schemes.hylo(coalg, alg)` stays the always-fused + * spelling. + */ +final class Cata[F[_], S, A] private[schemes] ( + val get: S => A, + private[schemes] val alg: (S, F[A]) => A, +) extends Optic[S, Unit, A, Unit, Direct]: + type X = Nothing + + def to(s: S): Direct[X, A] = Direct(get(s)) + def from(d: Direct[X, Unit]): Unit = () + + /** View as a plain [[Getter]] — re-enters Getter's fused composition fast paths (and resolves the + * read-compose overload tie an unascribed `getter.andThen(cata(...))` can hit). + */ + def asGetter: Getter[S, A] = Getter(get) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/CataM.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/CataM.scala new file mode 100644 index 00000000..046b082b --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/CataM.scala @@ -0,0 +1,9 @@ +package dev.constructive.eo +package schemes +package zoo + +/** Effectful fold-scheme citizen: carries its algebra for fusion. */ +final class CataM[M[_], F[_], S, A] private[schemes] ( + run: S => M[A], + private[schemes] val algM: (S, F[A]) => M[A], +) extends FoldM[M, S, A](run) 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..f0269dbf --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coattr.scala @@ -0,0 +1,20 @@ +package dev.constructive.eo +package schemes +package zoo + +/** 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]]) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala similarity index 57% rename from schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala rename to schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala index 1bf5dc5e..72fac896 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/CitizensM.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala @@ -1,12 +1,14 @@ package dev.constructive.eo package schemes - -import cats.{Monad, Traverse} +package zoo import data.{Forget, ForgetK} import optics.Optic -/** Effectful scheme citizens — the M-generic drivers' return types. Computational steps evolve in a +/** Generic effectful-fold citizen: `run: S => M[A]`. What `hyloM` and the fused + * `AnaM.andThen(CataM)` return. + * + * Effectful scheme citizens — the M-generic drivers' return types. Computational steps evolve in a * `Monad[M]` (the arbo `Calculator` shape: fetching a node's children is effectful), and the * results are **`Forget[M]`-carried** optics: `S => M[A]` worn as `Optic[S, Unit, A, Unit, * Forget[M]]`, composing same-carrier through `assocForgetMonad`. @@ -28,43 +30,15 @@ import optics.Optic * Widening hazard (the M-path mirror of `Ana.cross`'s): a widened `AnaM` still typechecks through * the generic trait `andThen` via `assocForgetMonad` — extensionally equal but MATERIALIZING * (`M[S]` built, then folded). `Schemes.hyloM` stays the always-fused M spelling; the fused member - * below requires the concrete types. - */ - -/** Generic effectful-fold citizen: `run: S => M[A]`. What `hyloM` and the fused - * `AnaM.andThen(CataM)` return. + * on [[AnaM]] requires the concrete types. + * + * `FoldM` is `open` (not `sealed`) so that [[CataM]] and [[AnaM]] — which live in the `zoo` + * subpackage — can extend it. Users may also wrap their own `S => M[A]` as a `FoldM` citizen; + * the constructor is public. */ -sealed class FoldM[M[_], S, A] private[schemes] (val run: S => M[A]) +class FoldM[M[_], S, A](val run: S => M[A]) extends Optic[S, Unit, A, Unit, Forget[M]]: type X = Nothing def to(s: S): Forget[M][X, A] = ForgetK(run(s)) def from(d: Forget[M][X, Unit]): Unit = () - -/** Effectful fold-scheme citizen: carries its algebra for fusion. */ -final class CataM[M[_], F[_], S, A] private[schemes] ( - run: S => M[A], - private[schemes] val algM: (S, F[A]) => M[A], -) extends FoldM[M, S, A](run) - -/** Effectful unfold-scheme citizen: `run: Seed => M[S]`, carrying the coalgebra + instances for - * fusion. - */ -final class AnaM[M[_], F[_], Seed, S] private[schemes] ( - run: Seed => M[S], - private[schemes] val coalgM: Seed => M[F[Seed]], -)(using - private[schemes] val M: Monad[M], - private[schemes] val F: Traverse[F], - private[schemes] val E: Embed[F, S], -) extends FoldM[M, Seed, S](run): - - /** The fused M seam — here `andThen` genuinely is the focus seam (`Forget[M]` Kleisli). One - * single-pass machine in `M` (the paired fold lifted through `tailRecM`): each node built once, - * folded immediately — no `M[S]` materialization of the whole structure. Requires the concrete - * types; widened operands fall back to the generic materializing `andThen`. - */ - def andThen[A](inner: CataM[M, F, S, A]): FoldM[M, Seed, A] = - val machine: Seed => M[(S, A)] = - Schemes.fusedPairedFoldM(coalgM, inner.algM)(using M, F, E) - new FoldM[M, Seed, A](seed => M.map(machine(seed))(_._2)) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Decorations.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala similarity index 53% rename from schemes/src/main/scala/dev/constructive/eo/schemes/Decorations.scala rename to schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala index 1befb361..8a8024bf 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Decorations.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala @@ -1,47 +1,6 @@ package dev.constructive.eo package schemes - -/** Decoration data for the typed recursion-scheme zoo (histo / futu) — hand-rolled, droste-style, - * rather than cats-free: no `Eval` suspension fields (the `foldLayered` machine never suspends), - * no dependency, shapes tuned to the engine. - * - * Space honesty: a histomorphism decorates **every** node with its full sub-result history, so a - * fold over n nodes retains O(n) [[Attr]] cells until the algebra releases them. That is inherent - * to course-of-value recursion, not an engine artifact. - */ - -/** 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 - -/** 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]]) +package zoo // =========================================================================================== // Gather / Scatter — the decoration optics, over the BiAffine carrier. @@ -76,11 +35,6 @@ enum Coattr[F[_], A]: type Gather[F[_], W, A] = optics.Optic[Unit, W, Unit, A, data.BiAffine] { type X = (Unit, F[W]) } -/** Unfold-side decoration optic: scatter (`to`, an affine match) + pointed unit (`from` on Step). - */ -type Scatter[F[_], W, A] = - optics.Optic[W, W, A, A, data.BiAffine] { type X = (F[W], Unit) } - /** Named fold-side decoration values. */ object Gather: import data.BiAffine @@ -127,47 +81,3 @@ object Gather: */ def histo[F[_], A]: Gather[F, Attr[F, A], A] = histoAny.asInstanceOf[Gather[F, Attr[F, A], A]] - -/** Named unfold-side decoration values. */ -object Scatter: - import data.BiAffine - import data.BiAffine.{Done, Step} - import optics.Optic - - private def scatterSideDone(name: String): Nothing = - throw new UnsupportedOperationException( - s"Scatter.$name: the pointed unit (from) is inhabited on the Step arm only" - ) - - private def mkAna[F[_], A]: Scatter[F, A, A] = - new Optic[A, A, A, A, BiAffine]: - type X = (F[A], Unit) - def to(w: A): BiAffine[X, A] = new Step[X, A]((), w) - def from(xb: BiAffine[X, A]): A = xb match - case s: Step[X, A] => s.b - case _: Done[X, A] => scatterSideDone("ana") - - private val anaAny: AnyRef = mkAna[[x] =>> Any, Any] - - /** The undecorated unfold — every slot is a seed; the unit is the identity. Identity-stable - * singleton (the generic driver takes the direct route on it). - */ - def ana[F[_], A]: Scatter[F, A, A] = anaAny.asInstanceOf[Scatter[F, A, A]] - - private def mkFutu[F[_], A]: Scatter[F, Coattr[F, A], A] = - new Optic[Coattr[F, A], Coattr[F, A], A, A, BiAffine]: - type X = (F[Coattr[F, A]], Unit) - def to(w: Coattr[F, A]): BiAffine[X, A] = w match - case Coattr.Pure(a) => new Step[X, A]((), a) - case Coattr.Roll(layer) => new Done[X, A](layer) - def from(xb: BiAffine[X, A]): Coattr[F, A] = xb match - case s: Step[X, A] => Coattr.Pure(s.b) - case _: Done[X, A] => scatterSideDone("futu") - - private val futuAny: AnyRef = mkFutu[[x] =>> Any, Any] - - /** Futumorphism decoration — `Pure(seed)` calls the coalgebra, `Roll(layer)` unrolls the prebuilt - * layer without a call; the unit is `Coattr.Pure`. - */ - def futu[F[_], A]: Scatter[F, Coattr[F, A], A] = - futuAny.asInstanceOf[Scatter[F, Coattr[F, A], A]] diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala new file mode 100644 index 00000000..3baaccfa --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala @@ -0,0 +1,56 @@ +package dev.constructive.eo +package schemes +package zoo + +// The Gather/Scatter family is documented in Gather.scala — see that file for +// the full banner comment covering the BiAffine carrier, X-pinning, and the +// design rationale for para/apo having no shipped Scatter/Gather values. + +/** Unfold-side decoration optic: scatter (`to`, an affine match) + pointed unit (`from` on Step). + */ +type Scatter[F[_], W, A] = + optics.Optic[W, W, A, A, data.BiAffine] { type X = (F[W], Unit) } + +/** Named unfold-side decoration values. */ +object Scatter: + import data.BiAffine + import data.BiAffine.{Done, Step} + import optics.Optic + + private def scatterSideDone(name: String): Nothing = + throw new UnsupportedOperationException( + s"Scatter.$name: the pointed unit (from) is inhabited on the Step arm only" + ) + + private def mkAna[F[_], A]: Scatter[F, A, A] = + new Optic[A, A, A, A, BiAffine]: + type X = (F[A], Unit) + def to(w: A): BiAffine[X, A] = new Step[X, A]((), w) + def from(xb: BiAffine[X, A]): A = xb match + case s: Step[X, A] => s.b + case _: Done[X, A] => scatterSideDone("ana") + + private val anaAny: AnyRef = mkAna[[x] =>> Any, Any] + + /** The undecorated unfold — every slot is a seed; the unit is the identity. Identity-stable + * singleton (the generic driver takes the direct route on it). + */ + def ana[F[_], A]: Scatter[F, A, A] = anaAny.asInstanceOf[Scatter[F, A, A]] + + private def mkFutu[F[_], A]: Scatter[F, Coattr[F, A], A] = + new Optic[Coattr[F, A], Coattr[F, A], A, A, BiAffine]: + type X = (F[Coattr[F, A]], Unit) + def to(w: Coattr[F, A]): BiAffine[X, A] = w match + case Coattr.Pure(a) => new Step[X, A]((), a) + case Coattr.Roll(layer) => new Done[X, A](layer) + def from(xb: BiAffine[X, A]): Coattr[F, A] = xb match + case s: Step[X, A] => Coattr.Pure(s.b) + case _: Done[X, A] => scatterSideDone("futu") + + private val futuAny: AnyRef = mkFutu[[x] =>> Any, Any] + + /** Futumorphism decoration — `Pure(seed)` calls the coalgebra, `Roll(layer)` unrolls the prebuilt + * layer without a call; the unit is `Coattr.Pure`. + */ + def futu[F[_], A]: Scatter[F, Coattr[F, A], A] = + futuAny.asInstanceOf[Scatter[F, Coattr[F, A], A]] diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorationsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorationsSpec.scala index 468de79c..775f297a 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorationsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorationsSpec.scala @@ -4,6 +4,7 @@ package schemes import org.specs2.mutable.Specification import schemes.samples.BinF +import zoo.* /** Unit checks for the decoration data ([[Attr]] / [[Coattr]]) — construction, projection, and * structural equality over a real pattern functor. The zoo members that consume them (`histo` / diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala index 17127eee..5d7963fb 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala @@ -17,6 +17,9 @@ import schemes.samples.{Bin, BinF} */ class FusionSpec extends Specification: + // Deep examples: one-at-a-time to bound peak heap (shared test JVM). + sequential + private def expand(n: Int): BinF[Int] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala index 2add00fc..2fb4ed1d 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala @@ -7,6 +7,7 @@ import data.BiAffine import data.BiAffine.{Done, Step} import optics.Optic import schemes.samples.{Bin, BinF} +import zoo.* /** Decoration laws — the per-value equations of the [[Gather]]/[[Scatter]] vocabulary, plus the * behaviour-identity of the re-derived `cata`/`ana` (the identity fast path must agree with the diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala new file mode 100644 index 00000000..76ae06a5 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala @@ -0,0 +1,107 @@ +package dev.constructive.eo +package schemes + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.Duration +import scala.concurrent.{Await, Future} + +import cats.Eval +import org.specs2.mutable.Specification + +import schemes.samples.{Bin, BinF} +import zoo.* + +/** Concurrency spec for the typed recursion-scheme engines. + * + * Motivation: [[Machines]] documents a thread-safety model — every machine allocates its own + * mutable state per invocation, and the only shared values ([[Machines.EmptyAnyRefs]], + * [[Machines.AscendToken]]) are immutable by construction. These tests exercise that claim with N + * concurrent tasks racing on shared prebuilt fixtures. + * + * Design: 16 `Future` tasks launched in parallel, each running a MIX of schemes over shared + * prebuilt optics and a shared input tree. Every task asserts its result equals the expected + * value. No sleeps; `Await` with `Duration.Inf` gives a deterministic pass/fail. + */ +class SchemesConcurrencySpec extends Specification: + + private val N = 16 + + // Shared fixtures — prebuilt once, used from all N concurrent tasks. + private val tree: Bin = + Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Branch(Bin.Leaf(3), Bin.Leaf(4))) + + // leaf sum of `tree` = 10 + private val ExpectedSum = 10 + + private val sumAlg: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + + // Prebuilt shared optics (shared across all tasks — the thread-safety claim under test). + private val sharedCata = Schemes.cata(sumAlg) + private val sharedPara = Schemes.para[BinF, Bin, Int] { (_, layer) => + layer match + case BinF.LeafF(n) => n + case BinF.BranchF((_, l), (_, r)) => l + r + } + private val sharedHisto = Schemes.histo[BinF, Bin, Int] { (_, layer) => + layer match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l.head + r.head + } + + // Per-task seed for ana/hylo/futu (varies per task to exercise different input paths). + private def expand(n: Int): BinF[Int] = + if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + + "N=16 concurrent tasks running a mix of cata/hylo/ana/para/histo/futu/cataM on shared fixtures all return correct results" >> { + val tasks: Seq[Future[Boolean]] = (1 to N).map { taskId => + Future { + // (a) cata leaf-sum on the shared Bin tree + val cataOk = sharedCata.get(tree) == ExpectedSum + + // (b) hylo from a per-task seed — independent fold, no shared state + val seed = taskId % 5 // seeds 0–4 + val hyloResult = Schemes + .hylo[BinF, Int, Int](expand, (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + ) + .get(seed) + val expectedHylo = Schemes.cata(sumAlg).get(Schemes.ana[BinF, Int, Bin](expand).reverseGet(seed)) + val hyloOk = hyloResult == expectedHylo + + // (c) ana builds a per-task Bin from a per-task seed, cata folds it back + val built: Bin = Schemes.ana[BinF, Int, Bin](expand).reverseGet(seed) + val anaOk = Schemes.cata(sumAlg).get(built) == expectedHylo + + // (d) para on the shared tree ignoring subterms == cata + val paraOk = sharedPara.get(tree) == ExpectedSum + + // (e) histo on the shared tree heads-only == cata + val histoOk = sharedHisto.get(tree) == ExpectedSum + + // (f) futu single-layer-per-step == ana + val futuCoalg: 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)) + val futuBuilt: Bin = Schemes.futu[BinF, Int, Bin](futuCoalg).reverseGet(seed) + val futuOk = Schemes.cata(sumAlg).get(futuBuilt) == expectedHylo + + // (g) cataM[Eval].run forced per task + val cataMResult = + Schemes + .cataM[Eval, BinF, Bin, Int]((s, fa) => Eval.now(sumAlg(s, fa))) + .run(tree) + .value + val cataMOk = cataMResult == ExpectedSum + + cataOk && hyloOk && anaOk && paraOk && histoOk && futuOk && cataMOk + } + } + + val results = Await.result(Future.sequence(tasks), Duration.Inf) + results.forall(identity) must beTrue + } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala index 0fd326f9..b4250d7e 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala @@ -6,6 +6,7 @@ import cats.{Eval, Id} import org.specs2.mutable.Specification import schemes.samples.{Bin, BinF} +import zoo.* /** The M-generic path (`cataM` / `anaM` / `hyloM`, the tailRecM-lifted machine): * 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 32444220..9fa3e0a9 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -12,6 +12,7 @@ import optics.{Getter, Lens, Optic} import optics.Optic.* // get, andThen, cross, foldMap import schemes.samples.{Bin, BinF, Rose, RoseF} +import zoo.* /** Behaviour spec for the typed pattern-functor schemes (`cata` / `ana` / `hylo`) and `fLayer`. * Companion law/coherence checks live in `SchemesLawsSpec`. @@ -23,6 +24,9 @@ import schemes.samples.{Bin, BinF, Rose, RoseF} */ class SchemesSpec extends Specification: + // Deep examples: one-at-a-time to bound peak heap (shared test JVM). + sequential + // 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))) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala index 3473e744..e91edec4 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala @@ -4,6 +4,7 @@ package schemes import org.specs2.mutable.Specification import schemes.samples.{Bin, BinF} +import zoo.* /** Behaviour + law spec for the named zoo (`para` / `apo` / `histo` / `futu`): * diff --git a/site/docs/schemes.md b/site/docs/schemes.md index f09f0403..e7e4a754 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -37,7 +37,8 @@ You write three things: the functor `F`, its `cats.Traverse`, and a `Basis` (`Pr ```scala mdoc:silent import cats.{Applicative, Eval, Traverse} -import dev.constructive.eo.schemes.{Basis, Cata} // `Schemes`, `Getter`, `get` already imported above +import dev.constructive.eo.schemes.Basis +import dev.constructive.eo.schemes.zoo.Cata // A binary tree… enum Bin: @@ -211,7 +212,7 @@ plus that child's own decorated layer — course-of-value recursion; note it inh O(n) `Attr` cells): ```scala mdoc:silent -import dev.constructive.eo.schemes.{Attr, Coattr} +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] { (_, layer) => @@ -265,7 +266,7 @@ consults a helper fold alongside each child's result — is a user-written gathe by the same `cata(decor)(galg)` driver as the named members: ```scala mdoc:silent -import dev.constructive.eo.schemes.Gather +import dev.constructive.eo.schemes.zoo.Gather import dev.constructive.eo.data.BiAffine import dev.constructive.eo.optics.Optic 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 + } From 73960fbc6c5827bae603ff889ccb30df81529a79 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 18:20:10 +0200 Subject: [PATCH 23/61] refactor(schemes): Gather/Scatter as classes, dispatch-free drivers, tail-recursive machines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 of PR review: - Gather/Scatter are abstract CLASSES extending Optic (no more type aliases + anonymous instances): a decoration is one named method — Gather.gather(layer, a) / Scatter.scatter(w) + Scatter.unit(a) — with the BiAffine to/from plumbing implemented once in the class. Named values are named final classes (Gather.Id/Histo, Scatter.Id/Futu); the docs zygo and all fixtures shrink to one-method extensions. - The identity fast-path dispatch is DELETED, not beautified: the plain cata(alg)/ana(coalg) overloads implement directly on the machine; the generic overloads always run generic, calling gather/scatter/unit directly — fully typed, ZERO asInstanceOf at the seam, and no per-node Step wrapper on the generic route (now byte-identical to the fast path: 361,321 vs 361,385 B/op, previously equal only via EA). Agreement stays law-pinned (cata(Gather.cata)(alg) == cata(alg)). Param renames: decor → gather / scatter. - Machines.scala rewritten functionally: the heap walks are ONE @tailrec loop each (descend/bubble phases, Ascend-sentinel encoded — the same loop-state design as the tailRecM machine; mutually-recursive phase functions would grow the JVM stack on cross-calls), frames on an immutable List for the cold pure-path walks, ArrayDeque retained in the M machine (it frames every interior node — deque slot reuse is CI-visible: List conses cost +98k B/op on eoHyloM), descend/ascend steps named (onDescend/onAscend). EmptyAnyRefs renamed NoChildren with the what/who/why-threadsafe scaladoc; splitLayer (inline) dedups the fused machines' two-pass projection. Perf: all pins hold byte-exactly (eoCata/eoHylo 361,385; eoCrossFused 820,065; eoFutu 655,249; eoHyloM 820,249 ≈ HEAD's 820,302 measured on the same box — the 623,665 in the previous commit message was a stale local measurement, not a real level; no regression either way). 509 tests green; mdoc clean. Co-Authored-By: Claude Fable 5 --- .../eo/bench/fixture/SchemesFixtures.scala | 11 +- .../constructive/eo/schemes/Machines.scala | 395 +++++++++--------- .../dev/constructive/eo/schemes/Schemes.scala | 105 +++-- .../constructive/eo/schemes/zoo/Cata.scala | 4 +- .../constructive/eo/schemes/zoo/FoldM.scala | 7 +- .../constructive/eo/schemes/zoo/Gather.scala | 98 ++--- .../constructive/eo/schemes/zoo/Scatter.scala | 86 ++-- .../eo/schemes/GatherScatterLawsSpec.scala | 62 +-- .../eo/schemes/SchemesConcurrencySpec.scala | 15 +- site/docs/schemes.md | 24 +- 10 files changed, 382 insertions(+), 425 deletions(-) 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 89febce1..90af16ef 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 @@ -113,8 +113,6 @@ object SchemesFixtures: 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, Gather} - import dev.constructive.eo.data.BiAffine - import dev.constructive.eo.optics.Optic // para: the same leaf-sum with subterms IGNORED — measures pure decoration // overhead (eo pairs subterms from the walked nodes; droste re-embeds each). @@ -161,10 +159,5 @@ object SchemesFixtures: // singleton, so the driver cannot take the identity fast path) — D4's // dispatch-cost honesty number. val userIdGather: Gather[BinF, Int, Int] = - new Optic[Unit, Int, Unit, Int, BiAffine]: - type X = (Unit, BinF[Int]) - def to(u: Unit): BiAffine[X, Unit] = - throw new UnsupportedOperationException("vestigial") - def from(xb: BiAffine[X, Int]): Int = xb match - case s: BiAffine.Step[X, Int] => s.b - case _: BiAffine.Done[X, Int] => throw new UnsupportedOperationException("fold-side") + new Gather[BinF, Int, Int]: + def gather(layer: BinF[Int], a: Int): Int = a diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala index 1f8b9479..416e0c04 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -1,32 +1,50 @@ 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 an immutable `List` 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). + * + * Mutation is confined to the per-node result buffer (an `Array[AnyRef]`, reused in place as the + * accumulator) and the frame's slot index — both owned by exactly one walk. 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. + * + * [[foldLayered]] and [[foldLayeredOr]] stay separate rather than unifying on the Or-shape: the + * plain engine's descend is `Either`-free, which keeps the hot cata/hylo path at its pinned + * allocation profile (CI: 361k B/op on the 8k-node fixture). * * ==Thread-safety model== * - * Every machine in this object allocates its mutable state (the frame deque, the `ret` variable, - * the per-node child/result arrays) **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. + * Every machine allocates its mutable state (the frame stack, the per-node child/result arrays) + * '''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: * - * - [[EmptyAnyRefs]]: a zero-length `Array[AnyRef]`, shared by all leaf layers (see its own + * - [[NoChildren]]: a zero-length `Array[AnyRef]`, shared by all leaf layers (see its own * scaladoc for the immutability argument). - * - [[AscendToken]]: a stable identity object used as the "ascend" marker in [[foldLayeredM]]'s - * loop. It is never written and carries no mutable state. + * - [[Ascend]]: a stable identity object used as the "ascend" marker in [[foldLayeredM]]'s loop. + * It is never written and carries 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. - * - * Note that '''concurrent forcing of a single `M[R]` value''' is a different question (not the - * concurrency of independent `run(s)` calls) and remains unsupported: the mutable frame deque is - * allocated '''inside''' the `M` action, so two concurrent forces of the exact same suspended - * `M[R]` could interleave their tailRecM steps and corrupt each other's deques. Each `run(s)` call - * returns an independent `M[R]`, and those are safe to force concurrently. + * '''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: @@ -35,35 +53,26 @@ private[schemes] object Machines: */ final val OnStackLimit = 512 - /** Shared zero-length children array for leaf nodes. + /** The shared "this layer has no children" sentinel. * - * '''What it is:''' a single `Array[AnyRef]` of length 0, allocated once and reused by every - * leaf layer encountered by [[childrenArr]], [[foldLayered]], [[foldLayeredOr]], and - * [[foldLayeredM]]. + * '''What it is:''' a single `Array[AnyRef]` of length 0, allocated once and returned by + * [[childrenArr]] for every leaf layer (`LeafF`-like constructors with no recursive slots). * - * '''Who uses it:''' [[childrenArr]] returns this value whenever `F.size(fn) == 0` (a leaf - * layer — `LeafF`-like constructors with no recursive slots). Since leaf layers are a common - * case in typed pattern functors, the shared sentinel avoids a per-leaf empty-array allocation. + * '''Who uses it:''' every engine, via [[childrenArr]] — 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. The store loops in all three engines - * (`foldLayered`, `foldLayeredOr`, `foldLayeredM`) are bounded by `arr.length`, so they - * execute zero iterations when `arr` is [[EmptyAnyRefs]]. No element is ever written into it. - * An immutable zero-length array is safe to share across any number of concurrent callers. + * '''Why it is thread-safe:''' the array has length 0 and no element is ever written into it — + * every store in the engines targets `arr(i)` for `i < arr.length`. An immutable zero-length + * array is safe to share across any number of concurrent walks. */ - private[schemes] val EmptyAnyRefs: Array[AnyRef] = new Array[AnyRef](0) + private[schemes] val NoChildren: Array[AnyRef] = new Array[AnyRef](0) /** Collect the children of typed layer `fn` into a flat `Array[AnyRef]`, single-pass via - * `ObjArrBuilder`. Returns [[EmptyAnyRefs]] for leaf layers (zero children) to avoid a per-leaf - * empty-array allocation. Used by [[foldLayered]], [[foldLayeredOr]], and [[foldLayeredM]] — one - * definition replaces the three identical nested `def childrenArr` that previously lived inside - * each engine. - * - * Leaf layers are a common case in typed pattern functors (every `LeafF`-like constructor - * carries no recursive slots), so the shared `EmptyAnyRefs` guard pays for itself. + * `ObjArrBuilder`; [[NoChildren]] for leaf layers. */ private[schemes] def childrenArr[F[_], N](fn: F[N])(using F: Traverse[F]): Array[AnyRef] = val n = F.size(fn).toInt - if n == 0 then EmptyAnyRefs + if n == 0 then NoChildren else val b = new data.ObjArrBuilder(n) val _ = F.foldLeft(fn, ()) { (_, child) => @@ -71,21 +80,37 @@ private[schemes] object Machines: } b.freezeArr - /** Sentinel op for [[foldLayeredM]]'s loop state — "ascend": consume `ret` against the top frame. - * Anything else on the loop is the node to descend into. + /** Sentinel op for [[foldLayeredM]]'s loop state — "ascend": consume the pending result against + * the top frame. Anything else on the loop is the node to descend into. + */ + private[schemes] object Ascend + + /** Placeholder for the `pending` slot 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 val NoResult: AnyRef = new AnyRef + + /** One suspended interior node: its layer, the child/result buffer (children overwritten in place + * by their results), and the index of the next slot awaiting a result. */ - private[schemes] object AscendToken + final private class Frame[F[_], N]( + val node: N, + val layer: F[N], + val arr: Array[AnyRef], + var i: 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. + * a positional vector. Leaf layers are phantom-recast (valid because pattern-functor leaves have + * no recursive slots by definition). */ private[schemes] def rebuildLayer[F[_], N, R](fn: F[N], out: Array[AnyRef])(using F: Traverse[F] ): F[R] = - if out.length == 0 then - fn.asInstanceOf[F[R]] // leaf: no N-slots, so F[N] is phantom-recast to F[R] + if out.length == 0 then fn.asInstanceOf[F[R]] else var i = -1 F.map(fn) { _ => @@ -100,7 +125,7 @@ private[schemes] object Machines: private[schemes] def rebuildLayerPaired[F[_], N, R](fn: F[N], out: Array[AnyRef])(using F: Traverse[F] ): F[(N, R)] = - if out.length == 0 then fn.asInstanceOf[F[(N, R)]] // leaf: no N-slots, phantom-recast + if out.length == 0 then fn.asInstanceOf[F[(N, R)]] else var i = -1 F.map(fn) { n => @@ -110,11 +135,10 @@ private[schemes] object Machines: /** 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), then calls `combine` with the - * node, its layer `F[N]`, and the children's results (positional, `Foldable` order). `combine` - * rebuilds the typed `F[R]` via [[rebuildLayer]] and applies the user's algebra / embed. Same - * `< 512`-on-stack / heap-`ArrayDeque` hybrid (and stack-safety) as [[unfoldFold]] / - * [[foldInPlace]]; the per-node child array is reused as the result accumulator (folded in - * place). + * node, its layer `F[N]`, and the children's results (positional, `Foldable` order). + * `< [[OnStackLimit]]` deep: plain tree recursion; past it, the descend/bubble heap walk (see + * the object scaladoc). 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], @@ -122,47 +146,45 @@ private[schemes] object Machines: )(using F: Traverse[F]): N => R = def heap(root: N): R = - final class Frame(val node: N, val layer: F[N], val arr: Array[AnyRef], var i: Int) - val stack = new java.util.ArrayDeque[Frame]() - var ret: AnyRef = null.asInstanceOf[AnyRef] - def enter(n: N): Unit = - val layer = expand(n) - val arr = childrenArr(layer) - if arr.length == 0 then ret = combine(n, layer, arr).asInstanceOf[AnyRef] - else stack.push(new Frame(n, layer, arr, 0)) - enter(root) - while !stack.isEmpty do - 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[N] - fr.i += 1 - enter(child) + // One tail-recursive loop over both phases, [[Ascend]]-sentinel encoded like + // [[foldLayeredM]] (mutually-recursive descend/bubble functions would grow the JVM + // stack on their cross-calls): `op` is either the node to descend into or [[Ascend]], + // in which case `pending` carries the result to store against the top frame. + @tailrec def loop(op: AnyRef, pending: AnyRef, stack: List[Frame[F, N]]): R = + if op ne Ascend then + val n = op.asInstanceOf[N] + val layer = expand(n) + val arr = childrenArr(layer) + if arr.length == 0 then loop(Ascend, combine(n, layer, arr).asInstanceOf[AnyRef], stack) + else loop(arr(0), NoResult, new Frame(n, layer, arr, 0) :: stack) else - ret = combine(fr.node, fr.layer, fr.arr).asInstanceOf[AnyRef] - val _ = stack.pop() - ret.asInstanceOf[R] + stack match + case Nil => pending.asInstanceOf[R] + case fr :: rest => + fr.arr(fr.i) = pending // overwrite the just-folded child's slot + fr.i += 1 + if fr.i < fr.arr.length then loop(fr.arr(fr.i), NoResult, stack) + else loop(Ascend, combine(fr.node, fr.layer, fr.arr).asInstanceOf[AnyRef], rest) + + loop(root.asInstanceOf[AnyRef], NoResult, Nil) def rec(n: N, depth: Int): R = if depth >= OnStackLimit then heap(n) else val layer = expand(n) val arr = childrenArr(layer) - val k = arr.length - if k == 0 then combine(n, layer, arr) - else - var i = 0 - while i < k do - arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] - i += 1 - combine(n, layer, arr) + var i = 0 + while i < arr.length do + arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] + i += 1 + combine(n, layer, arr) n => rec(n, 0) /** [[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 `< 512`-on-stack / - * heap-`ArrayDeque` hybrid and stack-safety as [[foldLayered]]. + * recursion, no projection) or `Right(layer)` (keep going). Same on-stack / descend-bubble + * hybrid and stack-safety as [[foldLayered]]. */ private[schemes] def foldLayeredOr[F[_], N, R]( expandOr: N => Either[R, F[N]], @@ -170,27 +192,26 @@ private[schemes] object Machines: )(using F: Traverse[F]): N => R = def heap(root: N): R = - final class Frame(val layer: F[N], val arr: Array[AnyRef], var i: Int) - val stack = new java.util.ArrayDeque[Frame]() - var ret: AnyRef = null.asInstanceOf[AnyRef] - def enter(n: N): Unit = expandOr(n) match - case Left(r) => ret = r.asInstanceOf[AnyRef] // graft: finished, by reference - case Right(layer) => - val arr = childrenArr(layer) - if arr.length == 0 then ret = combine(layer, arr).asInstanceOf[AnyRef] - else stack.push(new Frame(layer, arr, 0)) - enter(root) - while !stack.isEmpty do - val fr = stack.peek() - if fr.i > 0 then fr.arr(fr.i - 1) = ret - if fr.i < fr.arr.length then - val child = fr.arr(fr.i).asInstanceOf[N] - fr.i += 1 - enter(child) + // Same single-loop sentinel encoding as [[foldLayered]]'s heap walk; the graft arm + // (`Left`) feeds `pending` directly — finished, by reference. + @tailrec def loop(op: AnyRef, pending: AnyRef, stack: List[Frame[F, N]]): R = + if op ne Ascend then + expandOr(op.asInstanceOf[N]) match + case Left(r) => loop(Ascend, r.asInstanceOf[AnyRef], stack) + case Right(layer) => + val arr = childrenArr(layer) + if arr.length == 0 then loop(Ascend, combine(layer, arr).asInstanceOf[AnyRef], stack) + else loop(arr(0), NoResult, new Frame(null.asInstanceOf[N], layer, arr, 0) :: stack) else - ret = combine(fr.layer, fr.arr).asInstanceOf[AnyRef] - val _ = stack.pop() - ret.asInstanceOf[R] + stack match + case Nil => pending.asInstanceOf[R] + case fr :: rest => + fr.arr(fr.i) = pending + fr.i += 1 + if fr.i < fr.arr.length then loop(fr.arr(fr.i), NoResult, stack) + else loop(Ascend, combine(fr.layer, fr.arr).asInstanceOf[AnyRef], rest) + + loop(root.asInstanceOf[AnyRef], NoResult, Nil) def rec(n: N, depth: Int): R = if depth >= OnStackLimit then heap(n) @@ -199,100 +220,96 @@ private[schemes] object Machines: case Left(r) => r // graft: finished, by reference case Right(layer) => val arr = childrenArr(layer) - val k = arr.length - if k == 0 then combine(layer, arr) - else - var i = 0 - while i < k do - arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] - i += 1 - combine(layer, arr) + var i = 0 + while i < arr.length do + arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] + i += 1 + combine(layer, arr) n => rec(n, 0) // =========================================================================================== - // The M-generic path — the foldLayered state machine LIFTED into a Monad[M] (no M = Id - // special-case: that is what makes the fast-path agreement laws 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). 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). + // The M-generic path — the foldLayered walk 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 machine's state is mutable, so a branching / + // 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. + // 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. One `M`-action per `tailRecM` iteration: `Down(n)` runs `expandOr`, exits - * run `combine`; the mutable frame deque is allocated per-force (inside the `M`) so re-forcing - * the same `M[R]` value allocates fresh state. Concurrent forcing of a single `M[R]` value - * remains unsupported (mutable state, linear-M contract); each `run(s)` call is independent. + /** 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 a bare `AnyRef` — [[Ascend]] means "bubble", anything else is the + * node to descend into; the ascend transition is a hoisted constant. */ private[schemes] def foldLayeredM[M[_], F[_], N, R]( expandOr: N => M[Either[R, F[N]]], combine: (N, F[N], Array[AnyRef]) => M[R], )(using M: Monad[M], F: Traverse[F]): N => M[R] = - - final class Frame(val node: N, val layer: F[N], val arr: Array[AnyRef], var i: Int) - n0 => M.flatMap(M.unit) { _ => - val stack = new java.util.ArrayDeque[Frame]() - var ret: AnyRef = null.asInstanceOf[AnyRef] - // Op encoding (allocation-lean — CI 2026-06-12: per-event Either allocation - // dominated the M path's 1.6M B/op): the loop state is a bare AnyRef — the - // AscendToken sentinel means "consume ret against the top frame", anything else - // is the node to descend into. One Left per descend (vs nested Left(Right(n))); - // the ascend step is the hoisted constant. - val ascend: Either[AnyRef, R] = Left(AscendToken) - M.tailRecM[AnyRef, R](n0.asInstanceOf[AnyRef]) { op => - if op.asInstanceOf[AnyRef] ne AscendToken then - val n = op.asInstanceOf[N] - M.flatMap(expandOr(n)) { - case Left(r) => // graft/short-circuit arm (unused by v1 drivers) - ret = r.asInstanceOf[AnyRef] - M.pure(ascend) - case Right(layer) => - val arr = childrenArr(layer)(using F) - if arr.length == 0 then - // leaf: combine INLINE (a constant second bind — no frame, no extra event) - M.map(combine(n, layer, arr)) { r => - ret = r.asInstanceOf[AnyRef] - ascend - } - else - stack.push(new Frame(n, layer, arr, 0)) - M.pure(Left(arr(0))) - } - else if stack.isEmpty then M.pure(Right(ret.asInstanceOf[R])) + // ArrayDeque, not List: the M machine has no on-stack phase, so it frames EVERY + // interior node — the deque reuses its slots across pushes (zero steady-state + // allocation), where cons cells would cost one per node (CI-visible on eoHyloM). + val stack = new java.util.ArrayDeque[Frame[F, N]]() + var pending: AnyRef = null.asInstanceOf[AnyRef] + val ascend: Either[AnyRef, R] = Left(Ascend) + + inline def bubbled(r: AnyRef): Either[AnyRef, R] = + pending = r + ascend + + def onDescend(n: N): M[Either[AnyRef, R]] = + M.flatMap(expandOr(n)) { + case Left(r) => M.pure(bubbled(r.asInstanceOf[AnyRef])) // graft / short-circuit arm + case Right(layer) => + val arr = childrenArr(layer) + if arr.length == 0 then + // leaf: combine inline — no frame, no extra loop event + M.map(combine(n, layer, arr))(r => bubbled(r.asInstanceOf[AnyRef])) + else + stack.push(new Frame(n, layer, arr, 0)) + M.pure(Left(arr(0))) + } + + def onAscend(): M[Either[AnyRef, R]] = + val fr = stack.peek() + if fr == null then M.pure(Right(pending.asInstanceOf[R])) else - val fr = stack.peek() - fr.arr(fr.i) = ret // store the just-folded child's result + fr.arr(fr.i) = pending // store the just-folded child's result fr.i += 1 if fr.i < fr.arr.length then M.pure(Left(fr.arr(fr.i))) else - // last child stored: combine NOW (merged — no intermediate pure event) + // last child stored: combine now — no intermediate pure event M.map(combine(fr.node, fr.layer, fr.arr)) { r => val _ = stack.pop() - ret = r.asInstanceOf[AnyRef] - ascend + bubbled(r.asInstanceOf[AnyRef]) } + + M.tailRecM[AnyRef, R](n0.asInstanceOf[AnyRef]) { op => + if op ne Ascend then onDescend(op.asInstanceOf[N]) else onAscend() } } /** Single-pass paired machine in `M` backing the fused `AnaM.andThen(CataM)` — the M mirror of * [[fusedPairedFold]]: each node built once, folded immediately, no `M[S]` whole-structure - * materialization. Mirrors the pure version exactly: `F[S]` and `F[A]` are built straight from - * the out-array with two `F.map(fSeed)` passes and `var i = -1` counters, avoiding the - * `F[(S,A)]` intermediate. Leaf layers are phantom-recast (valid because pattern-functor leaves - * have no recursive slots by definition). + * materialization. */ private[schemes] def fusedPairedFoldM[M[_], F[_], Seed, S, A]( coalgM: Seed => M[F[Seed]], @@ -301,31 +318,13 @@ private[schemes] object Machines: foldLayeredM[M, F, Seed, (S, A)]( seed => M.map(coalgM(seed))(Right(_)), (_, fSeed, out) => - // Build F[S] and F[A] straight from the out-array — no F[(S,A)] intermediate. - val fS = - if out.length == 0 then fSeed.asInstanceOf[F[S]] - else - var i = -1 - F.map(fSeed) { _ => - i += 1 - out(i).asInstanceOf[(S, A)]._1 - } - val fA = - if out.length == 0 then fSeed.asInstanceOf[F[A]] - else - var i = -1 - F.map(fSeed) { _ => - i += 1 - out(i).asInstanceOf[(S, A)]._2 - } - val s = E.embed(fS) - M.map(algM(s, fA))(a => (s, a)), + val s = E.embed(splitLayer[F, Seed, S](fSeed, out, p => p._1.asInstanceOf[S])) + M.map(algM(s, splitLayer[F, Seed, A](fSeed, out, p => p._2.asInstanceOf[A])))(a => (s, a)), ) /** The single-pass paired machine backing the fused `Ana.cross(Cata)`: each node is built once * (the algebra is node-supplied — construction is semantically required), folded immediately, - * and released as the fold ascends. No full-tree retention, no second traversal. Leaf layers are - * phantom-recast (valid because pattern-functor leaves have no recursive slots by definition). + * and released as the fold ascends. No full-tree retention, no second traversal. */ private[schemes] def fusedPairedFold[F[_], Seed, S, A]( coalg: Seed => F[Seed], @@ -334,25 +333,25 @@ private[schemes] object Machines: foldLayered[F, Seed, (S, A)]( coalg, (_, fSeed, out) => - // Build F[S] and F[A] straight from the out-array — no F[(S, A)] intermediate - // (CI 2026-06-12: that third F-alloc per node put the fused cross ABOVE the - // materializing composition in B/op, 1049k vs 886k). - val fS = - if out.length == 0 then fSeed.asInstanceOf[F[S]] - else - var i = -1 - F.map(fSeed) { _ => - i += 1 - out(i).asInstanceOf[(S, A)]._1 - } - val fA = - if out.length == 0 then fSeed.asInstanceOf[F[A]] - else - var i = -1 - F.map(fSeed) { _ => - i += 1 - out(i).asInstanceOf[(S, A)]._2 - } - val s = E.embed(fS) - (s, alg(s, fA)), + val s = E.embed(splitLayer[F, Seed, S](fSeed, out, p => p._1.asInstanceOf[S])) + (s, alg(s, splitLayer[F, Seed, A](fSeed, out, p => p._2.asInstanceOf[A]))), ) + + /** Project one half of an `(S, A)`-pair out-array straight into a typed layer — the fused + * machines build `F[S]` and `F[A]` with two of these instead of one `F[(S, A)]` intermediate (CI + * 2026-06-12: that third F-alloc per node put the fused cross ABOVE the materializing + * composition in B/op, 1049k vs 886k). Leaf layers are phantom-recast (valid because + * pattern-functor leaves have no recursive slots by definition). + */ + private inline def splitLayer[F[_], N, T]( + fn: F[N], + out: Array[AnyRef], + inline half: ((Any, Any)) => T, + )(using F: Traverse[F]): F[T] = + if out.length == 0 then fn.asInstanceOf[F[T]] + else + var i = -1 + F.map(fn) { _ => + i += 1 + half(out(i).asInstanceOf[(Any, Any)]) + } 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 4492059b..b54e9cae 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -56,42 +56,37 @@ object Schemes: def cata[F[_], S, A]( alg: (S, F[A]) => A )(using F: Traverse[F], P: Project[F, S]): Cata[F, S, A] = - new Cata[F, S, A](cata[F, S, A, A](Gather.cata[F, A])(alg).get, alg) + new Cata[F, S, A]( + Machines.foldLayered[F, S, A]( + P.project, + (s, fs, out) => alg(s, Machines.rebuildLayer[F, S, A](fs, out)), + ), + alg, + ) /** Generalized (decorated) catamorphism — the gcata of the typed path, with the decoration - * supplied as a [[Gather]] optic value. Interior nodes apply `gather ∘ galg` (the decoration's - * `from` consuming `Step(layer, result)`); the **root applies `galg` alone** (droste's `gcata` - * shape). The named zoo members are instances: `cata(alg)` routes here with [[Gather.cata]] - * (recognised by identity — the direct, decoration-free engine path), `histo` with - * [[Gather.histo]]; user-written decorations (zygo, dyna, …) run the generic route, which pays - * one decoration dispatch + `Step` per node. + * supplied as a [[Gather]] optic value. Interior nodes apply `gather ∘ galg`; the **root applies + * `galg` alone** (droste's `gcata` shape). The driver calls [[Gather.gather]] directly — fully + * typed, no per-node carrier wrappers and no dispatch: the undecorated fold has its own overload + * above (the fast path), and `cata(Gather.cata)(galg)` is law-pinned equal to it. `histo` is the + * [[Gather.histo]] instance; user-written decorations (zygo, dyna, …) plug in the same way. * * (type-param order: `[F, S, W, A]` — compare [[ana]] `[F, A, W, S]`, which mirrors these in * input-before-output order: `A` is the input seed there, `S` the built output.) */ def cata[F[_], S, W, A]( - decor: Gather[F, W, A] + gather: Gather[F, W, A] )(galg: (S, F[W]) => A)(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = - if decor.asInstanceOf[AnyRef] eq Gather.cata[F, A] then - // W =:= A by construction of the singleton — the direct engine path, no decoration cost. - val alg = galg.asInstanceOf[(S, F[A]) => A] - Getter[S, A]( - Machines.foldLayered[F, S, A]( - P.project, - (s, fs, out) => alg(s, Machines.rebuildLayer[F, S, A](fs, out)), - ) - ) - else - val toW: S => W = Machines.foldLayered[F, S, W]( - P.project, - (s, fs, out) => - val fw = Machines.rebuildLayer[F, S, W](fs, out) - decor.from(new data.BiAffine.Step[(Unit, F[W]), A](fw, galg(s, fw))), - ) - Getter[S, A] { s => - val layer = P.project(s) - galg(s, F.map(layer)(toW)) - } + val toW: S => W = Machines.foldLayered[F, S, W]( + P.project, + (s, fs, out) => + val fw = Machines.rebuildLayer[F, S, W](fs, out) + gather.gather(fw, galg(s, fw)), + ) + Getter[S, A] { s => + val layer = P.project(s) + galg(s, F.map(layer)(toW)) + } /** Paramorphism over a typed pattern functor `F` — each child slot pairs the **original subterm** * with its folded result. Native route: the machine already walks real `S` nodes and keeps each @@ -140,7 +135,13 @@ object Schemes: def ana[F[_], Seed, S]( coalg: Seed => F[Seed] )(using F: Traverse[F], E: Embed[F, S]): Ana[F, Seed, S] = - new Ana[F, Seed, S](ana[F, Seed, Seed, S](Scatter.ana[F, Seed])(coalg).reverseGet, coalg) + new Ana[F, Seed, S]( + Machines.foldLayered[F, Seed, S]( + coalg, + (_, fSeed, out) => E.embed(Machines.rebuildLayer[F, Seed, S](fSeed, out)), + ), + coalg, + ) /** Apomorphism over a typed pattern functor `F` — per child slot the coalgebra answers * `Right(seed)` (keep unfolding) or `Left(s)` (an **already-finished subtree**). Native O(1) @@ -183,40 +184,28 @@ object Schemes: Review[S, A](a => build(Coattr.Pure(a))) /** Generalized (decorated) anamorphism — the gana of the typed path, with the decoration supplied - * as a [[Scatter]] optic value. Each `W` slot is scattered (the decoration's `to`): - * `Step(_, seed)` calls `gcoalg`, `Done(layer)` unrolls the prebuilt layer with **no coalgebra - * call**. The root seed enters through the decoration's pointed unit (`from` on the Step arm — - * gana's `pure`). `ana(coalg)` routes here with [[Scatter.ana]] (identity-recognised direct - * path); `futu` with [[Scatter.futu]]. (apo has no shipped Scatter value — distApo is inferior - * by construction; the O(1) graft belongs to the native `apo` engine. - * - * For user-written [[Scatter]] values, `Done.fst` MUST carry `F[W]` at runtime — the engine - * unrolls it directly as the next layer. + * as a [[Scatter]] optic value. Each `W` slot is scattered ([[Scatter.scatter]], called directly + * — fully typed, no per-node carrier wrappers and no dispatch: the undecorated unfold has its + * own overload above, and `ana(Scatter.ana)(gcoalg)` is law-pinned equal to it): `Right(seed)` + * calls `gcoalg`, `Left(layer)` unrolls the prebuilt layer with **no coalgebra call**. The root + * seed enters through the decoration's pointed unit ([[Scatter.unit]] — gana's `pure`). `futu` + * is the [[Scatter.futu]] instance. (apo has no shipped Scatter value — distApo is inferior by + * construction; the O(1) graft belongs to the native `apo` engine.) * * (type-param order: compare [[cata]] `[F, S, W, A]` — the fold mirror swaps `Seed`/`A`.) */ def ana[F[_], A, W, S]( - decor: Scatter[F, W, A] + scatter: Scatter[F, W, A] )(gcoalg: A => F[W])(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = - if decor.asInstanceOf[AnyRef] eq Scatter.ana[F, A] then - // W =:= A by construction of the singleton — the direct engine path. - val coalg = gcoalg.asInstanceOf[A => F[A]] - Review[S, A]( - Machines.foldLayered[F, A, S]( - coalg, - (_, fSeed, out) => E.embed(Machines.rebuildLayer[F, A, S](fSeed, out)), - ) - ) - else - val expand: W => F[W] = w => - decor.to(w) match - case st: data.BiAffine.Step[(F[W], Unit), A] => gcoalg(st.b) - case dn: data.BiAffine.Done[(F[W], Unit), A] => dn.fst - val build: W => S = Machines.foldLayered[F, W, S]( - expand, - (_, fw, out) => E.embed(Machines.rebuildLayer[F, W, S](fw, out)), - ) - Review[S, A](a => build(decor.from(new data.BiAffine.Step[(F[W], Unit), A]((), a)))) + val expand: W => F[W] = w => + scatter.scatter(w) match + case Right(a) => gcoalg(a) + case Left(layer) => layer + val build: W => S = Machines.foldLayered[F, W, S]( + expand, + (_, fw, out) => E.embed(Machines.rebuildLayer[F, W, S](fw, out)), + ) + Review[S, A](a => build(scatter.unit(a))) /** Hylomorphism over a typed pattern functor `F` — the **fused** refold `Seed => A`, building * **no intermediate `S`** (so it needs neither `Project` nor `Embed`, only `Traverse[F]`). 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 index eb96f46d..1329f8e7 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala @@ -15,8 +15,8 @@ import optics.{Getter, Optic} * perf-pinned encoding stays untouched): full generic composition via the trait members, plus * `.get` / `.reverseGet` as stored fields, the use-site-friendly shape. * - * Widening hazard, documented: binding a `Cata` (or `Ana`) to a wider type loses the fused - * `cross` overload — the generic trait `cross` still typechecks and is extensionally equal, but + * Widening hazard, documented: binding a `Cata` (or `Ana`) to a wider type loses the fused `cross` + * overload — the generic trait `cross` still typechecks and is extensionally equal, but * materializes the full intermediate structure. `Schemes.hylo(coalg, alg)` stays the always-fused * spelling. */ 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 index 72fac896..fcfc6d87 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala @@ -33,11 +33,10 @@ import optics.Optic * on [[AnaM]] requires the concrete types. * * `FoldM` is `open` (not `sealed`) so that [[CataM]] and [[AnaM]] — which live in the `zoo` - * subpackage — can extend it. Users may also wrap their own `S => M[A]` as a `FoldM` citizen; - * the constructor is public. + * subpackage — can extend it. Users may also wrap their own `S => M[A]` as a `FoldM` citizen; the + * constructor is public. */ -class FoldM[M[_], S, A](val run: S => M[A]) - extends Optic[S, Unit, A, Unit, Forget[M]]: +class FoldM[M[_], S, A](val run: S => M[A]) extends Optic[S, Unit, A, Unit, Forget[M]]: type X = Nothing def to(s: S): Forget[M][X, A] = ForgetK(run(s)) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala index 8a8024bf..432949b3 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala @@ -2,82 +2,82 @@ package dev.constructive.eo package schemes package zoo +import data.BiAffine +import data.BiAffine.{Done, Step} +import optics.Optic + // =========================================================================================== // Gather / Scatter — the decoration optics, over the BiAffine carrier. // // A generalized scheme's decoration is an optic whose existential leftover is one F-layer // (the names echo droste's Gather/Scatter and recursion-schemes' distCata/distHisto/...). -// Sides are pinned in the TYPE, eo-style (read-only/build-only citizens): +// Both are ABSTRACT CLASSES, not type aliases: a decoration is written by extending the +// class and implementing ONE named method (`gather` / `scatter` + `unit`) — the Optic +// `to`/`from` plumbing over BiAffine is implemented once, here, and the generic drivers in +// [[Schemes]] call the named methods directly (no per-node carrier wrappers on the generic +// route). Sides are pinned in the TYPE, eo-style (read-only/build-only citizens): // -// - Fold side (Gather): build-only. `from` is the GATHER — it consumes -// `Step(layer: F[W], result: A)` and produces the decoration `W` (histo's gather is -// literally the `Attr` constructor). The read side is vestigial (throws, the +// - Fold side (Gather): build-only. [[Gather.gather]] consumes one decorated layer +// `F[W]` plus the node's result `A` and produces the decoration `W` (histo's gather is +// literally the `Attr` constructor). The optic read side is vestigial (throws, the // `Unfold.algebra` precedent); `Done` never occurs on this side. // -// - Unfold side (Scatter): a full citizen. `to` is the SCATTER — an affine match -// answering each slot with `Step(_, seed)` (call the coalgebra) or `Done(layer: F[W])` -// (a prebuilt layer; unroll it, no coalgebra call). `from` on the Step arm is the -// POINTED unit — the seed injection `A => W` (gana's `pure`: ana = identity, futu = -// `Coattr.Pure`), giving the unit law `to(from(Step((), a))) == Step((), a)`. +// - Unfold side (Scatter): a full citizen. [[Scatter.scatter]] answers each slot with +// `Right(seed)` (call the coalgebra) or `Left(layer)` (a prebuilt `F[W]`; unroll it, no +// coalgebra call — the `Done` arm). [[Scatter.unit]] is the POINTED unit — the seed +// injection `A => W` (gana's `pure`: ana = identity, futu = `Coattr.Pure`), giving the +// unit law `to(from(Step((), a))) == Step((), a)`. // // X pinning per shape: gather side X = (Unit, F[W]) (Snd = the one-F-layer context); scatter -// side X = (F[W], Unit) (Fst = the prebuilt-layer Done payload). The generic drivers in -// [[Schemes]] are fully typed against these refinements — no casts at the seam. +// side X = (F[W], Unit) (Fst = the prebuilt-layer Done payload). // -// para and apo have NO generic values here: their generic routes are deliberately inferior +// para and apo have NO shipped values here: their generic routes are deliberately inferior // (para's gather would re-embed each subterm — droste's Gather.para; apo's scatter would // re-walk grafts through Project — distApo, O(graft)), and the native `Schemes.para` / // `Schemes.apo` engines subsume them. Their decoration semantics survive as law fixtures in // the test suite (GatherScatterLawsSpec), pinning the native routes to the definitions. // =========================================================================================== -/** Fold-side decoration optic: gather-only (build-only member). `from` = gather. */ -type Gather[F[_], W, A] = - optics.Optic[Unit, W, Unit, A, data.BiAffine] { type X = (Unit, F[W]) } +/** Fold-side decoration optic: gather-only (build-only member). Extend and implement [[gather]]; + * the `BiAffine` optic surface (`from` = gather on the `Step` arm, vestigial read) is provided + * here. + */ +abstract class Gather[F[_], W, A] extends Optic[Unit, W, Unit, A, BiAffine]: + type X = (Unit, F[W]) -/** Named fold-side decoration values. */ -object Gather: - import data.BiAffine - import data.BiAffine.{Done, Step} - import optics.Optic - - private def vestigialRead(name: String): Nothing = - throw new UnsupportedOperationException( - s"Gather.$name is gather-only (build-only): its read side is vestigial by specification" - ) + /** The gather: one decorated layer plus the node's result, to the node's decoration. */ + def gather(layer: F[W], a: A): W - private def foldSideDone(name: String): Nothing = + final def to(u: Unit): BiAffine[X, Unit] = throw new UnsupportedOperationException( - s"Gather.$name is a fold-side decoration: Done never occurs on the gather seam" + "Gather is gather-only (build-only): its read side is vestigial by specification" ) - private def mkCata[F[_], A]: Gather[F, A, A] = - new Optic[Unit, A, Unit, A, BiAffine]: - type X = (Unit, F[A]) - def to(u: Unit): BiAffine[X, Unit] = vestigialRead("cata") - def from(xb: BiAffine[X, A]): A = xb match - case s: Step[X, A] => s.b - case _: Done[X, A] => foldSideDone("cata") + final def from(xb: BiAffine[X, A]): W = xb match + case s: Step[X, A] => gather(s.snd, s.b) + case _: Done[X, A] => + throw new UnsupportedOperationException( + "Gather is a fold-side decoration: Done never occurs on the gather seam" + ) - private val cataAny: AnyRef = mkCata[[x] =>> Any, Any] +/** Named fold-side decoration values. */ +object Gather: - /** The undecorated fold — gather keeps the result, discards the layer. Identity-stable singleton: - * the generic driver recognises it and takes the direct (decoration-free) route. + /** The undecorated fold — gather keeps the result, discards the layer (`W = A`). With it, the + * generic `Schemes.cata(gather)(galg)` agrees with the direct `Schemes.cata(galg)` (law-pinned); + * the direct overload IS the fast path, no dispatch involved. */ - def cata[F[_], A]: Gather[F, A, A] = cataAny.asInstanceOf[Gather[F, A, A]] + final class Id[F[_], A] extends Gather[F, A, A]: + def gather(layer: F[A], a: A): A = a - private def mkHisto[F[_], A]: Gather[F, Attr[F, A], A] = - new Optic[Unit, Attr[F, A], Unit, A, BiAffine]: - type X = (Unit, F[Attr[F, A]]) - def to(u: Unit): BiAffine[X, Unit] = vestigialRead("histo") - def from(xb: BiAffine[X, A]): Attr[F, A] = xb match - case s: Step[X, A] => Attr(s.b, s.snd) - case _: Done[X, A] => foldSideDone("histo") - - private val histoAny: AnyRef = mkHisto[[x] =>> Any, Any] + /** @see [[Id]] */ + def cata[F[_], A]: Gather[F, A, A] = new Id[F, A] /** Histomorphism decoration — the gather IS the [[Attr]] constructor: each node keeps its result * plus its children's full decorated histories. */ - def histo[F[_], A]: Gather[F, Attr[F, A], A] = - histoAny.asInstanceOf[Gather[F, Attr[F, A], A]] + final class Histo[F[_], A] extends Gather[F, Attr[F, A], A]: + def gather(layer: F[Attr[F, A]], a: A): Attr[F, A] = Attr(a, layer) + + /** @see [[Histo]] */ + def histo[F[_], A]: Gather[F, Attr[F, A], A] = new Histo[F, A] diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala index 3baaccfa..013cb5d2 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala @@ -2,55 +2,65 @@ package dev.constructive.eo package schemes package zoo +import data.BiAffine +import data.BiAffine.{Done, Step} +import optics.Optic + // The Gather/Scatter family is documented in Gather.scala — see that file for // the full banner comment covering the BiAffine carrier, X-pinning, and the // design rationale for para/apo having no shipped Scatter/Gather values. -/** Unfold-side decoration optic: scatter (`to`, an affine match) + pointed unit (`from` on Step). +/** Unfold-side decoration optic: a full citizen. Extend and implement [[scatter]] (the affine + * match: `Right(seed)` keeps unfolding, `Left(layer)` is prebuilt — no coalgebra call) and + * [[unit]] (the pointed seed injection — gana's `pure`); the `BiAffine` optic surface (`to` = + * scatter, `from` on `Step` = unit) is provided here. */ -type Scatter[F[_], W, A] = - optics.Optic[W, W, A, A, data.BiAffine] { type X = (F[W], Unit) } +abstract class Scatter[F[_], W, A] extends Optic[W, W, A, A, BiAffine]: + type X = (F[W], Unit) + + /** The scatter: `Right(seed)` → call the coalgebra; `Left(layer)` → unroll as-is. */ + def scatter(w: W): Either[F[W], A] + + /** The pointed unit: inject a seed into the decoration (`Scatter.ana` = identity, `Scatter.futu` = + * `Coattr.Pure`). + */ + def unit(a: A): W + + final def to(w: W): BiAffine[X, A] = scatter(w) match + case Right(a) => new Step[X, A]((), a) + case Left(layer) => new Done[X, A](layer) + + final def from(xb: BiAffine[X, A]): W = xb match + case s: Step[X, A] => unit(s.b) + case _: Done[X, A] => + throw new UnsupportedOperationException( + "Scatter: the pointed unit (from) is inhabited on the Step arm only" + ) /** Named unfold-side decoration values. */ object Scatter: - import data.BiAffine - import data.BiAffine.{Done, Step} - import optics.Optic - - private def scatterSideDone(name: String): Nothing = - throw new UnsupportedOperationException( - s"Scatter.$name: the pointed unit (from) is inhabited on the Step arm only" - ) - - private def mkAna[F[_], A]: Scatter[F, A, A] = - new Optic[A, A, A, A, BiAffine]: - type X = (F[A], Unit) - def to(w: A): BiAffine[X, A] = new Step[X, A]((), w) - def from(xb: BiAffine[X, A]): A = xb match - case s: Step[X, A] => s.b - case _: Done[X, A] => scatterSideDone("ana") - - private val anaAny: AnyRef = mkAna[[x] =>> Any, Any] - - /** The undecorated unfold — every slot is a seed; the unit is the identity. Identity-stable - * singleton (the generic driver takes the direct route on it). - */ - def ana[F[_], A]: Scatter[F, A, A] = anaAny.asInstanceOf[Scatter[F, A, A]] - private def mkFutu[F[_], A]: Scatter[F, Coattr[F, A], A] = - new Optic[Coattr[F, A], Coattr[F, A], A, A, BiAffine]: - type X = (F[Coattr[F, A]], Unit) - def to(w: Coattr[F, A]): BiAffine[X, A] = w match - case Coattr.Pure(a) => new Step[X, A]((), a) - case Coattr.Roll(layer) => new Done[X, A](layer) - def from(xb: BiAffine[X, A]): Coattr[F, A] = xb match - case s: Step[X, A] => Coattr.Pure(s.b) - case _: Done[X, A] => scatterSideDone("futu") + /** The undecorated unfold — every slot is a seed; the unit is the identity (`W = A`). With it, + * the generic `Schemes.ana(scatter)(gcoalg)` agrees with the direct `Schemes.ana(gcoalg)` + * (law-pinned); the direct overload IS the fast path. + */ + final class Id[F[_], A] extends Scatter[F, A, A]: + def scatter(w: A): Either[F[A], A] = Right(w) + def unit(a: A): A = a - private val futuAny: AnyRef = mkFutu[[x] =>> Any, Any] + /** @see [[Id]] */ + def ana[F[_], A]: Scatter[F, A, A] = new Id[F, A] /** Futumorphism decoration — `Pure(seed)` calls the coalgebra, `Roll(layer)` unrolls the prebuilt * layer without a call; the unit is `Coattr.Pure`. */ - def futu[F[_], A]: Scatter[F, Coattr[F, A], A] = - futuAny.asInstanceOf[Scatter[F, Coattr[F, A], A]] + final class Futu[F[_], A] extends Scatter[F, Coattr[F, A], A]: + + def scatter(w: Coattr[F, A]): Either[F[Coattr[F, A]], A] = w match + case Coattr.Pure(a) => Right(a) + case Coattr.Roll(layer) => Left(layer) + + def unit(a: A): Coattr[F, A] = Coattr.Pure(a) + + /** @see [[Futu]] */ + def futu[F[_], A]: Scatter[F, Coattr[F, A], A] = new Futu[F, A] diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala index 2fb4ed1d..c7dee650 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala @@ -3,16 +3,14 @@ package schemes import org.specs2.mutable.Specification -import data.BiAffine import data.BiAffine.{Done, Step} -import optics.Optic import schemes.samples.{Bin, BinF} import zoo.* /** Decoration laws — the per-value equations of the [[Gather]]/[[Scatter]] vocabulary, plus the - * behaviour-identity of the re-derived `cata`/`ana` (the identity fast path must agree with the - * generic decoration route, proven by running a *fresh* user-written id decoration through the - * generic route and comparing). + * agreement of the direct `cata`/`ana` overloads with the generic decoration route at the identity + * decorations ([[Gather.cata]] / [[Scatter.ana]]) — there is no dispatch between the two: the + * direct overloads ARE the fast path, these laws pin the semantic equality. */ class GatherScatterLawsSpec extends Specification: @@ -30,25 +28,18 @@ class GatherScatterLawsSpec extends Specification: // live HERE, pinning the native Schemes.para / Schemes.apo engines. private def paraGather[F[_]: cats.Functor, S, A](using E: Embed[F, S]): Gather[F, (S, A), A] = - new Optic[Unit, (S, A), Unit, A, BiAffine]: - type X = (Unit, F[(S, A)]) - def to(u: Unit): BiAffine[X, Unit] = - throw new UnsupportedOperationException("gather-only") - def from(xb: BiAffine[X, A]): (S, A) = xb match - case s: Step[X, A] => (E.embed(cats.Functor[F].map(s.snd)(_._1)), s.b) - case _: Done[X, A] => throw new UnsupportedOperationException("fold-side Done") + new Gather[F, (S, A), A]: + def gather(layer: F[(S, A)], a: A): (S, A) = + (E.embed(cats.Functor[F].map(layer)(_._1)), a) private def apoScatter[F[_]: cats.Functor, S, A](using P: Project[F, S] ): Scatter[F, Either[S, A], A] = - new Optic[Either[S, A], Either[S, A], A, A, BiAffine]: - type X = (F[Either[S, A]], Unit) - def to(w: Either[S, A]): BiAffine[X, A] = w match - case Right(a) => new Step[X, A]((), a) - case Left(s) => new Done[X, A](cats.Functor[F].map(P.project(s))(Left(_))) - def from(xb: BiAffine[X, A]): Either[S, A] = xb match - case s: Step[X, A] => Right(s.b) - case _: Done[X, A] => throw new UnsupportedOperationException("unit on Step only") + new Scatter[F, Either[S, A], A]: + def scatter(w: Either[S, A]): Either[F[Either[S, A]], A] = w match + case Right(a) => Right(a) + case Left(s) => Left(cats.Functor[F].map(P.project(s))(Left(_))) + def unit(a: A): Either[S, A] = Right(a) // ----- gather-side equations ---------------------------------------------- @@ -79,11 +70,6 @@ class GatherScatterLawsSpec extends Specification: ) === 9 } - "Gather.cata is identity-stable across instantiations (the fast-path dispatch key)" >> { - (Gather.cata[BinF, Int].asInstanceOf[AnyRef] eq - Gather.cata[[x] =>> Option[x], String].asInstanceOf[AnyRef]) === true - } - "Gather.cata's vestigial read side throws" >> { val thrown = try { val _ = Gather.cata[BinF, Int].to(()); false } @@ -133,33 +119,15 @@ class GatherScatterLawsSpec extends Specification: // ----- re-derivation behaviour identity ------------------------------------ - // A FRESH user-written id gather — structurally Gather.cata but a distinct value, - // so the generic driver cannot take the identity fast path. - private val freshIdGather: Gather[BinF, Int, Int] = - new Optic[Unit, Int, Unit, Int, BiAffine]: - type X = (Unit, BinF[Int]) - def to(u: Unit): BiAffine[X, Unit] = throw new UnsupportedOperationException("vestigial") - def from(xb: BiAffine[X, Int]): Int = xb match - case s: Step[X, Int] => s.b - case _: Done[X, Int] => throw new UnsupportedOperationException("fold-side Done") - - private val freshIdScatter: Scatter[BinF, Int, Int] = - new Optic[Int, Int, Int, Int, BiAffine]: - type X = (BinF[Int], Unit) - def to(w: Int): BiAffine[X, Int] = new Step[X, Int]((), w) - def from(xb: BiAffine[X, Int]): Int = xb match - case s: Step[X, Int] => s.b - case _: Done[X, Int] => throw new UnsupportedOperationException("unit on Step only") - - "the generic decoration route agrees with the identity fast path on cata" >> { - Schemes.cata[BinF, Bin, Int, Int](freshIdGather)(sumAlg).get(tree) === + "the generic decoration route agrees with the direct overload on cata" >> { + Schemes.cata[BinF, Bin, Int, Int](Gather.cata[BinF, Int])(sumAlg).get(tree) === Schemes.cata[BinF, Bin, Int](sumAlg).get(tree) } - "the generic decoration route agrees with the identity fast path on ana" >> { + "the generic decoration route agrees with the direct overload on ana" >> { def expand(n: Int): BinF[Int] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) - Schemes.ana[BinF, Int, Int, Bin](freshIdScatter)(expand).reverseGet(5) === + Schemes.ana[BinF, Int, Int, Bin](Scatter.ana[BinF, Int])(expand).reverseGet(5) === Schemes.ana[BinF, Int, Bin](expand).reverseGet(5) } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala index 76ae06a5..ca9e7bf5 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala @@ -40,11 +40,13 @@ class SchemesConcurrencySpec extends Specification: // Prebuilt shared optics (shared across all tasks — the thread-safety claim under test). private val sharedCata = Schemes.cata(sumAlg) + private val sharedPara = Schemes.para[BinF, Bin, Int] { (_, layer) => layer match case BinF.LeafF(n) => n case BinF.BranchF((_, l), (_, r)) => l + r } + private val sharedHisto = Schemes.histo[BinF, Bin, Int] { (_, layer) => layer match case BinF.LeafF(n) => n @@ -64,13 +66,16 @@ class SchemesConcurrencySpec extends Specification: // (b) hylo from a per-task seed — independent fold, no shared state val seed = taskId % 5 // seeds 0–4 val hyloResult = Schemes - .hylo[BinF, Int, Int](expand, (_, fa) => - fa match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r + .hylo[BinF, Int, Int]( + expand, + (_, fa) => + fa match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r, ) .get(seed) - val expectedHylo = Schemes.cata(sumAlg).get(Schemes.ana[BinF, Int, Bin](expand).reverseGet(seed)) + val expectedHylo = + Schemes.cata(sumAlg).get(Schemes.ana[BinF, Int, Bin](expand).reverseGet(seed)) val hyloOk = hyloResult == expectedHylo // (c) ana builds a per-task Bin from a per-task seed, cata folds it back diff --git a/site/docs/schemes.md b/site/docs/schemes.md index e7e4a754..9b0e6ef9 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -17,10 +17,6 @@ Everything runs on one stack-safe, post-order machine family (heap-stacked past 512, not JVM-call-stacked) — safe to depths a hand-written recursion would overflow, tested at 10⁶. -> 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. - ```scala mdoc:silent import dev.constructive.eo.schemes.Schemes import dev.constructive.eo.optics.Getter @@ -267,19 +263,11 @@ by the same `cata(decor)(galg)` driver as the named members: ```scala mdoc:silent import dev.constructive.eo.schemes.zoo.Gather -import dev.constructive.eo.data.BiAffine -import dev.constructive.eo.optics.Optic def zygo[B](helper: BinF[B] => B): Gather[BinF, (B, Int), Int] = - new Optic[Unit, (B, Int), Unit, Int, BiAffine]: - type X = (Unit, BinF[(B, Int)]) - def to(u: Unit): BiAffine[X, Unit] = - throw new UnsupportedOperationException("gather-only") - def from(xb: BiAffine[X, Int]): (B, Int) = xb match - case s: BiAffine.Step[X, Int] => - (helper(summon[Traverse[BinF]].map(s.snd)(_._1)), s.b) - case _: BiAffine.Done[X, Int] => - throw new UnsupportedOperationException("fold-side") + new Gather[BinF, (B, Int), Int]: + def gather(layer: BinF[(B, Int)], a: Int): (B, Int) = + (helper(summon[Traverse[BinF]].map(layer)(_._1)), a) val leafCount: BinF[Int] => Int = { case BinF.LeafF(_) => 1; case BinF.BranchF(l, r) => l + r } @@ -331,3 +319,9 @@ laws are the graft-finality and round-trip equations in `cats-eo-laws`. Composit scoped to the shipped seams — the fused `cross`, the M-path `andThen`, and the generic drivers; BiAffine's full composition-matrix row is follow-up work, as are the elgot/coelgot decorations (the answer-level short-circuit, which the M machine's internals are already shaped for). + +--- + +> 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. From 8977550aaaec87c3f388d630acf64315c6c3cd6f Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 18:21:28 +0200 Subject: [PATCH 24/61] =?UTF-8?q?docs(brainstorms):=20X-is-the-decoration?= =?UTF-8?q?=20=E2=80=94=20the=20optics=E2=87=84schemes=20connection=20from?= =?UTF-8?q?=20PR=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...6-06-12-existential-x-is-the-decoration.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/brainstorms/2026-06-12-existential-x-is-the-decoration.md 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"). From fa8651bf0bd3b3fa89dde5d6b22762d54ec667e7 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 18:48:57 +0200 Subject: [PATCH 25/61] =?UTF-8?q?ci:=20compile=20test=20sources=20before?= =?UTF-8?q?=20running=20tests=20=E2=80=94=20fixes=20roving=20Kindlings=20m?= =?UTF-8?q?acro=20timeouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consecutive CI failures with `Macro 'KindlingsEncoder.deriveAsObject' timed out after 2000ms`, each at a DIFFERENT derive site (tests/ CrudRoundtripSpec.Item, then circeIntegration's MItem) — not flaky test code: sbt interleaves one module's test execution with another module's test compilation, and on the shared 2-vCPU runner the hearth/kindlings derivation macros (hardcoded 2s MIO budget, no -Xmacro-settings override in kindlings 0.1.x) lose the CPU-contention dice roll. A `Test/compile` preamble step runs all macro expansion with the whole CPU before any test executes; the `test` step then recompiles nothing. ci.yml regenerated via githubWorkflowGenerate. (The branch is incidental: its larger schemes test suites amplified the contention window, but the race exists on main too.) Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 +++ build.sbt | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6978522b..affd8b5c 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 diff --git a/build.sbt b/build.sbt index f3c8e3c7..b07009b0 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)"), + ), ) // ------------------------------------------------------------------- From 9cb8d51421aad513fec45054a8259bbd8ef0591d Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 12 Jun 2026 23:24:33 +0200 Subject: [PATCH 26/61] refactor(schemes): union-typed slots, one shared heap walk, named FLayer (PR review round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Slot[N, R] = N | R: buffer cells are union-typed — stores are cast-free (both halves conform), each phase narrows its reads at one documented point, and the buffer's only allocation-site cast is in childrenSlots. Companion unions type the loop state itself: Op[N] = N | Ascend.type, Pending[R] = R | NoResult.type — the sentinel encoding is now honest in the signatures. - NoResult is an object (like Ascend); Frame.arr → slots, i → next; childrenArr → childrenSlots. - The two pure heap loops were near-duplicates: now ONE shared heapWalk(root, expandOr, combine) — foldLayered passes a constant Right, foldLayeredOr adapts its combine. It runs only past OnStackLimit (cold), so plain function params; the hot on-stack recursions stay specialized per engine. Inside, the loop delegates to transparent inline descend/bubble phase helpers — loop calls stay in tail position after inlining, @tailrec verifies. (heapWalk itself cannot be inline: nested inline methods are an implementation restriction.) - fLayer's anonymous Optic → named private FLayer class. All pins byte-exact: eoCata/eoHylo 361,385/361,387; generic route 361,320; eoCrossFused 820,067; eoHyloM 820,299; graft O(1). 509 tests green. Co-Authored-By: Claude Fable 5 --- .../constructive/eo/schemes/Machines.scala | 330 +++++++++--------- .../dev/constructive/eo/schemes/Schemes.scala | 14 +- .../eo/schemes/SchemesConcurrencySpec.scala | 2 +- 3 files changed, 182 insertions(+), 164 deletions(-) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala index 416e0c04..d501a0b7 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -10,34 +10,33 @@ import cats.{Monad, Traverse} * ==Shape== * * Every engine is the same two-phase walk — '''descend''' (peel a layer, push a [[Frame]] per - * interior node onto an immutable `List` 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 + * 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). + * 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`. * - * Mutation is confined to the per-node result buffer (an `Array[AnyRef]`, reused in place as the - * accumulator) and the frame's slot index — both owned by exactly one walk. 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. - * - * [[foldLayered]] and [[foldLayeredOr]] stay separate rather than unifying on the Or-shape: the - * plain engine's descend is `Either`-free, which keeps the hot cata/hylo path at its pinned - * allocation profile (CI: 361k B/op on the 8k-node fixture). + * 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 child/result arrays) - * '''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. + * 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 `Array[AnyRef]`, shared by all leaf layers (see its own - * scaladoc for the immutability argument). - * - [[Ascend]]: a stable identity object used as the "ascend" marker in [[foldLayeredM]]'s loop. - * It is never written and carries no mutable state. + * - [[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. @@ -53,61 +52,80 @@ private[schemes] object Machines: */ 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 shared "this layer has no children" sentinel. * - * '''What it is:''' a single `Array[AnyRef]` of length 0, allocated once and returned by - * [[childrenArr]] for every leaf layer (`LeafF`-like constructors with no recursive slots). + * '''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 [[childrenArr]] — leaf layers are the most common case in - * typed pattern functors, so the shared sentinel avoids a per-leaf empty-array allocation. + * '''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 `arr(i)` for `i < arr.length`. An immutable zero-length + * 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[schemes] val NoChildren: Array[AnyRef] = new Array[AnyRef](0) + private val NoChildren: Array[AnyRef] = new Array[AnyRef](0) - /** Collect the children of typed layer `fn` into a flat `Array[AnyRef]`, single-pass via - * `ObjArrBuilder`; [[NoChildren]] for leaf layers. + /** 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 childrenArr[F[_], N](fn: F[N])(using F: Traverse[F]): Array[AnyRef] = + private[schemes] def childrenSlots[F[_], N, R](fn: F[N])(using + F: Traverse[F] + ): Array[Slot[N, R]] = val n = F.size(fn).toInt - if n == 0 then NoChildren - else - val b = new data.ObjArrBuilder(n) - val _ = F.foldLeft(fn, ()) { (_, child) => - b.unsafeAppend(child.asInstanceOf[AnyRef]) - } - b.freezeArr - - /** Sentinel op for [[foldLayeredM]]'s loop state — "ascend": consume the pending result against - * the top frame. Anything else on the loop is the node to descend into. - */ - private[schemes] object Ascend - - /** Placeholder for the `pending` slot 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 val NoResult: AnyRef = new AnyRef + 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 buffer (children overwritten in place - * by their results), and the index of the next slot awaiting a result. + /** 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]( + final private class Frame[F[_], N, R]( val node: N, val layer: F[N], - val arr: Array[AnyRef], - var i: Int, + 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). + * no recursive slots by definition); non-leaf reads narrow the slot union (every cell holds an + * `R` by the time a layer is rebuilt). */ - private[schemes] def rebuildLayer[F[_], N, R](fn: F[N], out: Array[AnyRef])(using + 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 fn.asInstanceOf[F[R]] @@ -122,7 +140,7 @@ private[schemes] object Machines: * from `out` (positional, `Foldable` order). The subterms come from the layer the machine * already holds — no re-`embed`. */ - private[schemes] def rebuildLayerPaired[F[_], N, R](fn: F[N], out: Array[AnyRef])(using + private[schemes] 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 fn.asInstanceOf[F[(N, R)]] @@ -133,106 +151,100 @@ private[schemes] object Machines: (n, out(i).asInstanceOf[R]) } + /** 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[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(slots(0).asInstanceOf[N], NoResult, new Frame(n, layer, slots, 0) :: stack) + + transparent inline def bubble: R = stack match + case Nil => pending.asInstanceOf[R] // the walk's final result + case fr :: rest => + fr.slots(fr.next) = pending.asInstanceOf[R] // overwrite the just-folded child's slot + fr.next += 1 + if fr.next < fr.slots.length then loop(fr.slots(fr.next).asInstanceOf[N], NoResult, stack) + else loop(Ascend, combine(fr.node, fr.layer, fr.slots), rest) + + op match + case Ascend => bubble + case n => descend(n.asInstanceOf[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), then calls `combine` with the * node, its layer `F[N]`, and the children's results (positional, `Foldable` order). - * `< [[OnStackLimit]]` deep: plain tree recursion; past it, the descend/bubble heap walk (see - * the object scaladoc). Stack-safe for any *terminating* `expand` (a non-terminating one - * exhausts the heap — `OutOfMemoryError` — rather than the stack). + * `< [[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[N], Array[AnyRef]) => R, + combine: (N, F[N], Array[Slot[N, R]]) => R, )(using F: Traverse[F]): N => R = - def heap(root: N): R = - // One tail-recursive loop over both phases, [[Ascend]]-sentinel encoded like - // [[foldLayeredM]] (mutually-recursive descend/bubble functions would grow the JVM - // stack on their cross-calls): `op` is either the node to descend into or [[Ascend]], - // in which case `pending` carries the result to store against the top frame. - @tailrec def loop(op: AnyRef, pending: AnyRef, stack: List[Frame[F, N]]): R = - if op ne Ascend then - val n = op.asInstanceOf[N] - val layer = expand(n) - val arr = childrenArr(layer) - if arr.length == 0 then loop(Ascend, combine(n, layer, arr).asInstanceOf[AnyRef], stack) - else loop(arr(0), NoResult, new Frame(n, layer, arr, 0) :: stack) - else - stack match - case Nil => pending.asInstanceOf[R] - case fr :: rest => - fr.arr(fr.i) = pending // overwrite the just-folded child's slot - fr.i += 1 - if fr.i < fr.arr.length then loop(fr.arr(fr.i), NoResult, stack) - else loop(Ascend, combine(fr.node, fr.layer, fr.arr).asInstanceOf[AnyRef], rest) - - loop(root.asInstanceOf[AnyRef], NoResult, Nil) - def rec(n: N, depth: Int): R = - if depth >= OnStackLimit then heap(n) + if depth >= OnStackLimit then heapWalk(n, m => Right(expand(m)), combine) else val layer = expand(n) - val arr = childrenArr(layer) + val slots = childrenSlots[F, N, R](layer) var i = 0 - while i < arr.length do - arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] + while i < slots.length do + slots(i) = rec(slots(i).asInstanceOf[N], depth + 1) i += 1 - combine(n, layer, arr) + combine(n, layer, slots) n => rec(n, 0) /** [[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 / descend-bubble - * hybrid and stack-safety as [[foldLayered]]. + * 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[N], Array[AnyRef]) => R, + combine: (F[N], Array[Slot[N, R]]) => R, )(using F: Traverse[F]): N => R = - def heap(root: N): R = - // Same single-loop sentinel encoding as [[foldLayered]]'s heap walk; the graft arm - // (`Left`) feeds `pending` directly — finished, by reference. - @tailrec def loop(op: AnyRef, pending: AnyRef, stack: List[Frame[F, N]]): R = - if op ne Ascend then - expandOr(op.asInstanceOf[N]) match - case Left(r) => loop(Ascend, r.asInstanceOf[AnyRef], stack) - case Right(layer) => - val arr = childrenArr(layer) - if arr.length == 0 then loop(Ascend, combine(layer, arr).asInstanceOf[AnyRef], stack) - else loop(arr(0), NoResult, new Frame(null.asInstanceOf[N], layer, arr, 0) :: stack) - else - stack match - case Nil => pending.asInstanceOf[R] - case fr :: rest => - fr.arr(fr.i) = pending - fr.i += 1 - if fr.i < fr.arr.length then loop(fr.arr(fr.i), NoResult, stack) - else loop(Ascend, combine(fr.layer, fr.arr).asInstanceOf[AnyRef], rest) - - loop(root.asInstanceOf[AnyRef], NoResult, Nil) - def rec(n: N, depth: Int): R = - if depth >= OnStackLimit then heap(n) + if depth >= OnStackLimit then + heapWalk(n, expandOr, (_, layer, slots) => combine(layer, slots)) else expandOr(n) match case Left(r) => r // graft: finished, by reference case Right(layer) => - val arr = childrenArr(layer) + val slots = childrenSlots[F, N, R](layer) var i = 0 - while i < arr.length do - arr(i) = rec(arr(i).asInstanceOf[N], depth + 1).asInstanceOf[AnyRef] + while i < slots.length do + slots(i) = rec(slots(i).asInstanceOf[N], depth + 1) i += 1 - combine(layer, arr) + combine(layer, slots) n => rec(n, 0) // =========================================================================================== - // The M-generic path — the foldLayered walk 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 + // 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). // @@ -255,55 +267,56 @@ private[schemes] object Machines: * 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 a bare `AnyRef` — [[Ascend]] means "bubble", anything else is the - * node to descend into; the ascend transition is a hoisted constant. + * 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[N], Array[AnyRef]) => M[R], + combine: (N, F[N], Array[Slot[N, R]]) => M[R], )(using M: Monad[M], F: Traverse[F]): N => M[R] = n0 => M.flatMap(M.unit) { _ => - // ArrayDeque, not List: the M machine has no on-stack phase, so it frames EVERY - // interior node — the deque reuses its slots across pushes (zero steady-state - // allocation), where cons cells would cost one per node (CI-visible on eoHyloM). - val stack = new java.util.ArrayDeque[Frame[F, N]]() - var pending: AnyRef = null.asInstanceOf[AnyRef] - val ascend: Either[AnyRef, R] = Left(Ascend) - - inline def bubbled(r: AnyRef): Either[AnyRef, R] = + 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[AnyRef, R]] = + def onDescend(n: N): M[Either[Op[N], R]] = M.flatMap(expandOr(n)) { - case Left(r) => M.pure(bubbled(r.asInstanceOf[AnyRef])) // graft / short-circuit arm - case Right(layer) => - val arr = childrenArr(layer) - if arr.length == 0 then + 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, layer, arr))(r => bubbled(r.asInstanceOf[AnyRef])) + M.map(combine(n, layer, slots))(bubbled) else - stack.push(new Frame(n, layer, arr, 0)) - M.pure(Left(arr(0))) + stack.push(new Frame(n, layer, slots, 0)) + M.pure(Left(slots(0).asInstanceOf[N])) } - def onAscend(): M[Either[AnyRef, R]] = + def onAscend(): M[Either[Op[N], R]] = val fr = stack.peek() if fr == null then M.pure(Right(pending.asInstanceOf[R])) else - fr.arr(fr.i) = pending // store the just-folded child's result - fr.i += 1 - if fr.i < fr.arr.length then M.pure(Left(fr.arr(fr.i))) + fr.slots(fr.next) = pending.asInstanceOf[R] // store the just-folded child's result + fr.next += 1 + if fr.next < fr.slots.length then M.pure(Left(fr.slots(fr.next).asInstanceOf[N])) else // last child stored: combine now — no intermediate pure event - M.map(combine(fr.node, fr.layer, fr.arr)) { r => + M.map(combine(fr.node, fr.layer, fr.slots)) { r => val _ = stack.pop() - bubbled(r.asInstanceOf[AnyRef]) + bubbled(r) } - M.tailRecM[AnyRef, R](n0.asInstanceOf[AnyRef]) { op => - if op ne Ascend then onDescend(op.asInstanceOf[N]) else onAscend() + M.tailRecM[Op[N], R](n0) { op => + op match + case Ascend => onAscend() + case n => onDescend(n.asInstanceOf[N]) } } @@ -318,8 +331,8 @@ private[schemes] object Machines: foldLayeredM[M, F, Seed, (S, A)]( seed => M.map(coalgM(seed))(Right(_)), (_, fSeed, out) => - val s = E.embed(splitLayer[F, Seed, S](fSeed, out, p => p._1.asInstanceOf[S])) - M.map(algM(s, splitLayer[F, Seed, A](fSeed, out, p => p._2.asInstanceOf[A])))(a => (s, a)), + val s = E.embed(splitLayer[F, Seed, (S, A), S](fSeed, out, _._1)) + M.map(algM(s, splitLayer[F, Seed, (S, A), A](fSeed, out, _._2)))(a => (s, a)), ) /** The single-pass paired machine backing the fused `Ana.cross(Cata)`: each node is built once @@ -333,25 +346,26 @@ private[schemes] object Machines: foldLayered[F, Seed, (S, A)]( coalg, (_, fSeed, out) => - val s = E.embed(splitLayer[F, Seed, S](fSeed, out, p => p._1.asInstanceOf[S])) - (s, alg(s, splitLayer[F, Seed, A](fSeed, out, p => p._2.asInstanceOf[A]))), + val s = E.embed(splitLayer[F, Seed, (S, A), S](fSeed, out, _._1)) + (s, alg(s, splitLayer[F, Seed, (S, A), A](fSeed, out, _._2))), ) - /** Project one half of an `(S, A)`-pair out-array straight into a typed layer — the fused + /** Project one half of a pair-resulted slot buffer straight into a typed layer — the fused * machines build `F[S]` and `F[A]` with two of these instead of one `F[(S, A)]` intermediate (CI * 2026-06-12: that third F-alloc per node put the fused cross ABOVE the materializing * composition in B/op, 1049k vs 886k). Leaf layers are phantom-recast (valid because - * pattern-functor leaves have no recursive slots by definition). + * pattern-functor leaves have no recursive slots by definition); non-leaf reads narrow the slot + * union to the pair `P` (every cell holds a result by combine time). */ - private inline def splitLayer[F[_], N, T]( + private inline def splitLayer[F[_], N, P, T]( fn: F[N], - out: Array[AnyRef], - inline half: ((Any, Any)) => T, + out: Array[Slot[N, P]], + inline half: P => T, )(using F: Traverse[F]): F[T] = if out.length == 0 then fn.asInstanceOf[F[T]] else var i = -1 F.map(fn) { _ => i += 1 - half(out(i).asInstanceOf[(Any, Any)]) + half(out(i).asInstanceOf[P]) } 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 b54e9cae..0aced605 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -40,11 +40,15 @@ object Schemes: * needs `Monad[F]`, which most pattern functors are not — so `fLayer` is a one-layer lens on the * structure, not a composable traversal. */ - def fLayer[F[_], S](using P: Project[F, S], E: Embed[F, S]): Optic[S, S, S, S, Forget[F]] = - new Optic[S, S, S, S, Forget[F]]: - type X = Any - def to(s: S): Forget[F][X, S] = ForgetK(P.project(s)) - def from(fs: Forget[F][X, S]): S = E.embed(fs.value) + def fLayer[F[_], S](using Project[F, S], Embed[F, S]): Optic[S, S, S, S, Forget[F]] = + new FLayer[F, S] + + /** The named class behind [[fLayer]] — the single-layer peel/glue optic. */ + final private class FLayer[F[_], S](using P: Project[F, S], E: Embed[F, S]) + extends Optic[S, S, S, S, Forget[F]]: + type X = Any + def to(s: S): Forget[F][X, S] = ForgetK(P.project(s)) + def from(fs: Forget[F][X, S]): S = E.embed(fs.value) /** Catamorphism over a typed pattern functor `F`, as a composable `Getter`. `alg` sees the * original node `S` (paramorphism-flavored) plus its already-folded children as a typed `F[A]`. diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala index ca9e7bf5..a79ab088 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala @@ -14,7 +14,7 @@ import zoo.* /** Concurrency spec for the typed recursion-scheme engines. * * Motivation: [[Machines]] documents a thread-safety model — every machine allocates its own - * mutable state per invocation, and the only shared values ([[Machines.EmptyAnyRefs]], + * mutable state per invocation, and the only shared values ([[Machines.NoChildren]], * [[Machines.AscendToken]]) are immutable by construction. These tests exercise that claim with N * concurrent tasks racing on shared prebuilt fixtures. * From b22cbd00cc762a4de4e07887a48315f7d8028345 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sat, 13 Jun 2026 00:09:36 +0200 Subject: [PATCH 27/61] =?UTF-8?q?docs(benchmarks):=20regression=20check=20?= =?UTF-8?q?across=20the=20review-refactor=20window=20=E2=80=94=20all=20cle?= =?UTF-8?q?ar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paired evidence, both boxes agreeing: - Local A/B (e671c58 vs aa1dd43, -prof gc): every eo row byte-identical (Δ ≤ 2 B/op) across the six review commits — zoo package split, Machines extraction, class-based Gather/Scatter, dispatch deletion, tail-recursive heapWalk dedup, union-typed slots all allocation-neutral. - CI (run 27445302118 vs 27398242244): same verdict, byte-identical eo rows — except eoHyloM, IMPROVED 929 472 → 820 298 B/op (the round-3 typed bubbled continuation; cumulative −49% from the original 1.6M). Doc updated: eoHyloM row + floor prose (~2.3×, two-round optimisation history), provenance line covers both runs. Co-Authored-By: Claude Fable 5 --- site/docs/benchmarks.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index d28797c9..8048e489 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -443,7 +443,7 @@ perfect binary `Bin` tree (8 191 nodes). An earlier untyped `PSVec` path was **r 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 (run 27398242244, 2026-06-12; B/op is the trustworthy metric on the shared +Core rows (runs 27398242244 + 27445302118, 2026-06-12/13 — every eo row byte-identical across the two except the optimised `eoHyloM`; B/op is the trustworthy metric on the shared runner): | Method | B/op | vs droste (B/op) | @@ -486,7 +486,7 @@ As above, B/op is the trustworthy column; ns/op is directional. | `eoCataGenericRoute` | 162 279 | 362 313 | 2.20× | | `drosteCata` | 56 542 | 164 824 | 1× | | `eoHyloF` | 180 767 | 361 385 | — | -| `eoHyloM` | 343 202 | 929 472 | — | +| `eoHyloM` | 303 295 | 820 298 | — | | `eoCrossFused` | 239 393 | 820 066 | — | | `eoCrossMaterialized` | 375 824 | 885 579 | — | @@ -512,10 +512,11 @@ Six results: 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 - 929 472 B/op vs 361 385 for `hylo` (~2.6×) — that delta is the `tailRecM` step-event - wrapping, the price of arbitrary-monad algebras. This run includes the M-machine - optimisation: the previous run (27384569800) had `eoHyloM` at 1 606 586 B/op, a **−42%** - improvement. + 820 298 B/op vs 361 385 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%**. - **Fused `cross` beats materialising on both axes.** Composing `ana` into `cata` via `cross` fuses into one pass — 239 393 ns / 820 066 B/op vs 375 824 ns / 885 579 B/op for build-the-tree-then-fold (~1.6× faster, no intermediate tree). The fused path also dropped From ac476f4a46fc23601086df887e860efb0c044d83 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sat, 13 Jun 2026 10:32:37 +0200 Subject: [PATCH 28/61] refactor(schemes): centralise Machines' unchecked narrowings into 5 named inline helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ~22 scattered .asInstanceOf casts in the engine bodies are now five named, documented inline helpers — childAt / resultAt (Slot → N / R by index discipline), nodeOf (Op → N, non-Ascend arm), forced (Pending → R, Ascend arm), leafRecast (empty-leaf F[A] → F[B] phantom). Each carries the walk invariant that justifies it; the loop bodies are now cast-free. This is centralisation, not elimination — and deliberately so. With abstract N/R there is NO safe narrowing: verified that `case n: N` is rejected under -Werror ("the type test for N cannot be checked at runtime") and that Scala 3 does not subtract a matched singleton from a union binder (`N | Ascend.type` minus `Ascend.type` is still the union, not N). So the cast is the honest expression of an invariant the type system can't see — but it now lives in ONE named place per kind, audited and counted, instead of inline with the logic. Remaining two casts are always-safe boundary casts (the ObjArrBuilder AnyRef widening + the Array[AnyRef]→Array[Slot] erasure-identity reinterpret in childrenSlots), not narrowings — labelled as such. inline = zero overhead: all six bench pins byte-exact (eoCata/eoHylo 361,385; eoCrossFused 820,065; eoHyloM 820,249; eoPara 557,945; graft O(1)). 509 tests green. Co-Authored-By: Claude Fable 5 --- .../constructive/eo/schemes/Machines.scala | 69 ++++++++++++++----- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala index d501a0b7..00151c0c 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -74,6 +74,39 @@ private[schemes] object Machines: /** 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 @@ -128,12 +161,12 @@ private[schemes] object Machines: 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 fn.asInstanceOf[F[R]] + if out.length == 0 then leafRecast(fn) else var i = -1 F.map(fn) { _ => i += 1 - out(i).asInstanceOf[R] + resultAt(out(i)) } /** [[rebuildLayer]]'s paramorphic sibling: pair each original child `N` with its folded result @@ -143,12 +176,12 @@ private[schemes] object Machines: private[schemes] 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 fn.asInstanceOf[F[(N, R)]] + if out.length == 0 then leafRecast(fn) else var i = -1 F.map(fn) { n => i += 1 - (n, out(i).asInstanceOf[R]) + (n, resultAt(out(i))) } /** The descend/bubble heap walk shared by [[foldLayered]] and [[foldLayeredOr]] (previously two @@ -173,19 +206,19 @@ private[schemes] object Machines: case Right(layer) => val slots = childrenSlots[F, N, R](layer) if slots.length == 0 then loop(Ascend, combine(n, layer, slots), stack) - else loop(slots(0).asInstanceOf[N], NoResult, new Frame(n, layer, slots, 0) :: stack) + else loop(childAt(slots(0)), NoResult, new Frame(n, layer, slots, 0) :: stack) transparent inline def bubble: R = stack match - case Nil => pending.asInstanceOf[R] // the walk's final result + case Nil => forced(pending) // the walk's final result case fr :: rest => - fr.slots(fr.next) = pending.asInstanceOf[R] // overwrite the just-folded child's slot + fr.slots(fr.next) = forced(pending) // overwrite the just-folded child's slot fr.next += 1 - if fr.next < fr.slots.length then loop(fr.slots(fr.next).asInstanceOf[N], NoResult, stack) + 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(n.asInstanceOf[N]) // the op union's other inhabitant is the node + case n => descend(nodeOf(n)) // the op union's other inhabitant is the node loop(root, NoResult, Nil) @@ -208,7 +241,7 @@ private[schemes] object Machines: val slots = childrenSlots[F, N, R](layer) var i = 0 while i < slots.length do - slots(i) = rec(slots(i).asInstanceOf[N], depth + 1) + slots(i) = rec(childAt(slots(i)), depth + 1) i += 1 combine(n, layer, slots) @@ -234,7 +267,7 @@ private[schemes] object Machines: val slots = childrenSlots[F, N, R](layer) var i = 0 while i < slots.length do - slots(i) = rec(slots(i).asInstanceOf[N], depth + 1) + slots(i) = rec(childAt(slots(i)), depth + 1) i += 1 combine(layer, slots) @@ -296,16 +329,16 @@ private[schemes] object Machines: M.map(combine(n, layer, slots))(bubbled) else stack.push(new Frame(n, layer, slots, 0)) - M.pure(Left(slots(0).asInstanceOf[N])) + M.pure(Left(childAt(slots(0)))) } def onAscend(): M[Either[Op[N], R]] = val fr = stack.peek() - if fr == null then M.pure(Right(pending.asInstanceOf[R])) + if fr == null then M.pure(Right(forced(pending))) else - fr.slots(fr.next) = pending.asInstanceOf[R] // store the just-folded child's result + 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(fr.slots(fr.next).asInstanceOf[N])) + 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, fr.layer, fr.slots)) { r => @@ -316,7 +349,7 @@ private[schemes] object Machines: M.tailRecM[Op[N], R](n0) { op => op match case Ascend => onAscend() - case n => onDescend(n.asInstanceOf[N]) + case n => onDescend(nodeOf(n)) } } @@ -362,10 +395,10 @@ private[schemes] object Machines: out: Array[Slot[N, P]], inline half: P => T, )(using F: Traverse[F]): F[T] = - if out.length == 0 then fn.asInstanceOf[F[T]] + if out.length == 0 then leafRecast(fn) else var i = -1 F.map(fn) { _ => i += 1 - half(out(i).asInstanceOf[P]) + half(resultAt(out(i))) } From ff6d8b0d4b56ffb3a62b1a938a79966d81f508d9 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sat, 13 Jun 2026 12:20:42 +0200 Subject: [PATCH 29/61] refactor(schemes)!: ana/apo/futu forward Getter; drop cross + clone classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure `ana` shipped backward as `Review[S, Seed]` while its effectful twin `anaM` shipped forward as `FoldM[Seed, S]` — a directional inconsistency. `.cross` existed only to undo that backwardness, and it broke `getter.andThen(cata)` with an ambiguous overload. Fix: pure `ana`/`apo`/`futu` are now forward `Getter[Seed, S]` (mirroring `anaM`); `cata` is `Getter[S, A]`. The materialising refold is plain `ana.andThen(cata) : Getter[Seed, A]` via core's fused `Getter.andThen`. Deleted: `Optic.cross` usage in schemes, the `Cata`/`Ana`/`CataM`/`AnaM` clone classes (+ `asGetter`/`asReview`), and `Machines.fusedPairedFold`/`fusedPairedFoldM`/`splitLayer`. M path: `cataM`/`anaM` return bare `FoldM`; a concrete `FoldM.andThen` member (Kleisli `M.flatMap`, materialising, `using Monad[M]`) is more specific than the generic `Optic` overloads, so it wins resolution and stays a `FoldM`. `Schemes.hylo`/`hyloM` remain the fused spellings. Empirical (-prof gc): eoHylo fused 361,384 B/op (unchanged pin); ana.andThen(cata) materialising 885,580 B/op == manual cata.get(ana.get()) 885,580 (byte-identical). Bench eoCrossFused/eoCrossMaterialized renamed eoRefoldAndThen/eoRefoldManual. 74/74 schemes tests green; full root test green; mdoc 0 errors. Co-Authored-By: Claude Fable 5 --- .../constructive/eo/bench/SchemesBench.scala | 54 ++++++++-------- ...06-11-001-feat-biaffine-scheme-zoo-plan.md | 13 ++++ .../constructive/eo/schemes/Machines.scala | 50 --------------- .../dev/constructive/eo/schemes/Schemes.scala | 61 +++++++++---------- .../dev/constructive/eo/schemes/zoo/Ana.scala | 44 ------------- .../constructive/eo/schemes/zoo/AnaM.scala | 27 -------- .../constructive/eo/schemes/zoo/Cata.scala | 35 ----------- .../constructive/eo/schemes/zoo/CataM.scala | 9 --- .../constructive/eo/schemes/zoo/FoldM.scala | 28 ++++++--- .../constructive/eo/schemes/FusionSpec.scala | 49 +++++++-------- .../eo/schemes/GatherScatterLawsSpec.scala | 14 ++--- .../eo/schemes/SchemesConcurrencySpec.scala | 6 +- .../eo/schemes/SchemesLawsSpec.scala | 6 +- .../eo/schemes/SchemesMSpec.scala | 12 ++-- .../constructive/eo/schemes/SchemesSpec.scala | 15 +++-- .../eo/schemes/SchemesZooSpec.scala | 16 ++--- site/docs/schemes.md | 47 +++++++------- 17 files changed, 172 insertions(+), 314 deletions(-) delete mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala delete mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/AnaM.scala delete mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala delete mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/CataM.scala 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 6dd7982b..3da419b7 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -13,12 +13,12 @@ import dev.constructive.eo.schemes.Schemes /** Recursion schemes — `cata` / `ana` / `hylo` — four ways, on the same workload: * - * - **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. + * - **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 @@ -47,7 +47,7 @@ class SchemesBench extends JmhDefaults: // 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 eoAnaG = Schemes.ana[BinF, Int, Bin](eoTypedCoalg) // Getter[Int, Bin] val drosteCataF: Fix[BinF] => Int = scheme.cata(drosteSum) val drosteHyloF: Int => Int = scheme.hylo(drosteSum, drosteBuild) @@ -64,7 +64,7 @@ class SchemesBench extends JmhDefaults: @Benchmark def handHylo: Int = SchemesFixtures.handHylo(Depth) // ----- ana: build the tree from a seed (materializing) --------------------- - @Benchmark def eoAna: Bin = eoAnaR.reverseGet(Depth) + @Benchmark def eoAna: Bin = eoAnaG.get(Depth) @Benchmark def drosteAna: Fix[BinF] = drosteAnaF(Depth) @Benchmark def handAna: Bin = handBuild(Depth) @@ -72,20 +72,20 @@ class SchemesBench extends JmhDefaults: 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 eoApoG = 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 eoFutuG = 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 eoApo: Bin = eoApoG.get(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 eoFutu: Bin = eoFutuG.get(Depth) @Benchmark def drosteFutu: Fix[BinF] = drosteFutuFn(Depth) // ----- apo with ONE BIG GRAFT. VERIFIED (the D6 check): droste's zoo.apo @@ -95,26 +95,32 @@ class SchemesBench extends JmhDefaults: // 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 => + val eoApoGraftG = 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) + 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 = eoApoGraftG.get(0) @Benchmark def drosteApoGraft: Fix[BinF] = drosteApoGraftFn(0) - // ----- fused cross vs materialized composition (the fusion law's alloc pin) -- + // ----- materializing refold: andThen-spelling vs manual (both build the Bin) -- + // Post directional-flip, `ana.andThen(cata)` IS the materializing hylo (builds the + // Bin, then folds) — the two spellings should allocate identically. The *fused* + // (no-intermediate-Bin) contrast is `eoHylo` above (~half the B/op). - val eoCrossFusedG = Schemes.ana[BinF, Int, Bin](eoTypedCoalg).cross(Schemes.cata(eoTypedSum)) + val eoRefoldAndThenG = Schemes.ana[BinF, Int, Bin](eoTypedCoalg).andThen(Schemes.cata(eoTypedSum)) - @Benchmark def eoCrossFused: Int = eoCrossFusedG.get(Depth) - @Benchmark def eoCrossMaterialized: Int = eoCataG.get(eoAnaR.reverseGet(Depth)) + @Benchmark def eoRefoldAndThen: Int = eoRefoldAndThenG.get(Depth) + @Benchmark def eoRefoldManual: Int = eoCataG.get(eoAnaG.get(Depth)) - // ----- generic decoration route (user-written Decor, no identity fast path) -- + // ----- generic decoration route (user-written Gather, no identity fast path) -- val eoCataGenericG = Schemes.cata[BinF, Bin, Int, Int](userIdGather)(eoTypedSum) 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 index 2b1f94bd..cb236dda 100644 --- 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 @@ -37,6 +37,19 @@ apo, histo, futu — built on three structural commitments (v2): 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. + + > **Superseded (2026-06-13, directional flip).** The `.cross` framing above was + > a directional inconsistency: pure `ana` shipped as a *backward* `Review[S, Seed]` + > while its effectful twin `anaM` was a *forward* `FoldM[Seed, S]`. `.cross` was + > only needed to undo that backwardness. Resolution: pure `ana`/`apo`/`futu` are now + > **forward `Getter[Seed, S]`** (mirroring `anaM`), so the materialising refold is + > plain `ana.andThen(cata) : Getter[Seed, A]` via the core fused `Getter.andThen` — + > no `cross`, no `Cata`/`Ana`/`CataM`/`AnaM` clone classes (deleted). `Schemes.hylo` + > stays the fused (no-intermediate-`S`) spelling. Note the flip makes `ana.andThen(cata)` + > genuinely *materialising* (885k B/op, == manual `cata.get(ana.get())`); the old + > fused-pairs `.cross` member (820k) is gone, but the real fusion win lives in `hylo` + > (361k, unchanged). The M path mirrors this: `anaM.andThen(cataM)` is a concrete + > `FoldM.andThen` (Kleisli `flatMap`, materialising); `hyloM` is the fused spelling. 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 diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala index 00151c0c..9b0fe819 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -352,53 +352,3 @@ private[schemes] object Machines: case n => onDescend(nodeOf(n)) } } - - /** Single-pass paired machine in `M` backing the fused `AnaM.andThen(CataM)` — the M mirror of - * [[fusedPairedFold]]: each node built once, folded immediately, no `M[S]` whole-structure - * materialization. - */ - private[schemes] def fusedPairedFoldM[M[_], F[_], Seed, S, A]( - coalgM: Seed => M[F[Seed]], - algM: (S, F[A]) => M[A], - )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): Seed => M[(S, A)] = - foldLayeredM[M, F, Seed, (S, A)]( - seed => M.map(coalgM(seed))(Right(_)), - (_, fSeed, out) => - val s = E.embed(splitLayer[F, Seed, (S, A), S](fSeed, out, _._1)) - M.map(algM(s, splitLayer[F, Seed, (S, A), A](fSeed, out, _._2)))(a => (s, a)), - ) - - /** The single-pass paired machine backing the fused `Ana.cross(Cata)`: each node is built once - * (the algebra is node-supplied — construction is semantically required), folded immediately, - * and released as the fold ascends. No full-tree retention, no second traversal. - */ - private[schemes] def fusedPairedFold[F[_], Seed, S, A]( - coalg: Seed => F[Seed], - alg: (S, F[A]) => A, - )(using F: Traverse[F], E: Embed[F, S]): Seed => (S, A) = - foldLayered[F, Seed, (S, A)]( - coalg, - (_, fSeed, out) => - val s = E.embed(splitLayer[F, Seed, (S, A), S](fSeed, out, _._1)) - (s, alg(s, splitLayer[F, Seed, (S, A), A](fSeed, out, _._2))), - ) - - /** Project one half of a pair-resulted slot buffer straight into a typed layer — the fused - * machines build `F[S]` and `F[A]` with two of these instead of one `F[(S, A)]` intermediate (CI - * 2026-06-12: that third F-alloc per node put the fused cross ABOVE the materializing - * composition in B/op, 1049k vs 886k). Leaf layers are phantom-recast (valid because - * pattern-functor leaves have no recursive slots by definition); non-leaf reads narrow the slot - * union to the pair `P` (every cell holds a result by combine time). - */ - private inline def splitLayer[F[_], N, P, T]( - fn: F[N], - out: Array[Slot[N, P]], - inline half: P => T, - )(using F: Traverse[F]): F[T] = - if out.length == 0 then leafRecast(fn) - else - var i = -1 - F.map(fn) { _ => - i += 1 - half(resultAt(out(i))) - } 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 0aced605..060a83bb 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -4,16 +4,17 @@ package schemes import cats.{Monad, Traverse} import data.{Forget, ForgetK} -import optics.{Getter, Optic, Review} +import optics.{Getter, Optic} import zoo.* /** 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` — * algebras pattern-match `F`'s *named constructors*, no positional indexing. * - * - [[cata]] folds (`Cata`, Getter-shaped); [[ana]] builds (`Ana`, Review-shaped); [[hylo]] is - * the **fused** zero-`S` refold. `ana(c).cross(cata(a))` fuses (single pass, no full-tree - * retention). + * - [[cata]] folds (`Getter[S, A]`); [[ana]] unfolds (`Getter[Seed, S]` — forward, mirroring + * [[anaM]]'s `FoldM[Seed, S]`); both are plain `Getter`s, so `ana.andThen(cata) : Getter[Seed, + * A]` is the materialising hylo via the core fused `Getter.andThen`. [[hylo]] is the **fused** + * zero-`S` refold (builds no intermediate `S`); `ana.andThen(cata) == hylo` is the hylo law. * - The zoo: [[para]] (subterms paired from the walked nodes), [[apo]] (O(1) graft), [[histo]] / * [[futu]] (course-of-value / multi-layer, via [[Attr]] / [[Coattr]]). Decorated generically * through the [[Gather]]/[[Scatter]] decoration optics (over the `BiAffine` carrier) — @@ -59,13 +60,12 @@ object Schemes: */ def cata[F[_], S, A]( alg: (S, F[A]) => A - )(using F: Traverse[F], P: Project[F, S]): Cata[F, S, A] = - new Cata[F, S, A]( + )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = + Getter[S, A]( Machines.foldLayered[F, S, A]( P.project, (s, fs, out) => alg(s, Machines.rebuildLayer[F, S, A](fs, out)), - ), - alg, + ) ) /** Generalized (decorated) catamorphism — the gcata of the typed path, with the decoration @@ -138,13 +138,12 @@ object Schemes: */ def ana[F[_], Seed, S]( coalg: Seed => F[Seed] - )(using F: Traverse[F], E: Embed[F, S]): Ana[F, Seed, S] = - new Ana[F, Seed, S]( + )(using F: Traverse[F], E: Embed[F, S]): Getter[Seed, S] = + Getter[Seed, S]( Machines.foldLayered[F, Seed, S]( coalg, (_, fSeed, out) => E.embed(Machines.rebuildLayer[F, Seed, S](fSeed, out)), - ), - coalg, + ) ) /** Apomorphism over a typed pattern functor `F` — per child slot the coalgebra answers @@ -156,7 +155,7 @@ object Schemes: */ def apo[F[_], A, S]( coalg: A => F[Either[S, A]] - )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = + )(using F: Traverse[F], E: Embed[F, S]): Getter[A, S] = val run = Machines.foldLayeredOr[F, Either[S, A], S]( { case Left(s) => Left(s) @@ -164,7 +163,7 @@ object Schemes: }, (fw, out) => E.embed(Machines.rebuildLayer[F, Either[S, A], S](fw, out)), ) - Review[S, A](a => run(Right(a))) + Getter[A, S](a => run(Right(a))) /** Futumorphism over a typed pattern functor `F` — the coalgebra may emit **multiple layers per * step** ([[Coattr]]: `Pure` keeps unfolding, `Roll` is a prebuilt layer unrolled with no @@ -177,7 +176,7 @@ object Schemes: */ def futu[F[_], A, S]( coalg: A => F[Coattr[F, A]] - )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = + )(using F: Traverse[F], E: Embed[F, S]): Getter[A, S] = val expand: Coattr[F, A] => F[Coattr[F, A]] = case Coattr.Pure(a) => coalg(a) case Coattr.Roll(layer) => layer @@ -185,7 +184,7 @@ object Schemes: expand, (_, fw, out) => E.embed(Machines.rebuildLayer[F, Coattr[F, A], S](fw, out)), ) - Review[S, A](a => build(Coattr.Pure(a))) + Getter[A, S](a => build(Coattr.Pure(a))) /** Generalized (decorated) anamorphism — the gana of the typed path, with the decoration supplied * as a [[Scatter]] optic value. Each `W` slot is scattered ([[Scatter.scatter]], called directly @@ -200,7 +199,7 @@ object Schemes: */ def ana[F[_], A, W, S]( scatter: Scatter[F, W, A] - )(gcoalg: A => F[W])(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = + )(gcoalg: A => F[W])(using F: Traverse[F], E: Embed[F, S]): Getter[A, S] = val expand: W => F[W] = w => scatter.scatter(w) match case Right(a) => gcoalg(a) @@ -209,14 +208,15 @@ object Schemes: expand, (_, fw, out) => E.embed(Machines.rebuildLayer[F, W, S](fw, out)), ) - Review[S, A](a => build(scatter.unit(a))) + Getter[A, S](a => build(scatter.unit(a))) /** Hylomorphism over a typed pattern functor `F` — the **fused** refold `Seed => A`, building * **no intermediate `S`** (so it needs neither `Project` nor `Embed`, only `Traverse[F]`). * `coalg` unfolds a seed into one typed layer; `alg` folds the layer's results to `A` (the seed * is supplied, paramorphism-flavored). Stack-safe (the [[Machines.foldLayered]] machine). Equal - * to `ana(coalg).cross(cata(alg))` for a *pure* algebra (the hylo law); for a node-reading para - * algebra the two agree only under the seed↔`embed(coalg(seed))` correspondence. + * to the materialising `ana(coalg).andThen(cata(alg))` for a *pure* algebra (the hylo law) — + * `hylo` fuses it into one pass with no intermediate `S`; for a node-reading para algebra the + * two agree only under the seed↔`embed(coalg(seed))` correspondence. */ def hylo[F[_], Seed, A]( coalg: Seed => F[Seed], @@ -230,32 +230,31 @@ object Schemes: ) /** Effectful catamorphism — the algebra runs in `M` (`(S, F[A]) => M[A]`); the layer peel stays - * the pure `Project`. Returns the `Forget[M]`-carried [[CataM]] citizen; consume via `.run`. + * the pure `Project`. Returns the `Forget[M]`-carried [[zoo.FoldM]] citizen; consume via `.run`. */ def cataM[M[_], F[_], S, A]( algM: (S, F[A]) => M[A] - )(using M: Monad[M], F: Traverse[F], P: Project[F, S]): CataM[M, F, S, A] = - new CataM[M, F, S, A]( + )(using M: Monad[M], F: Traverse[F], P: Project[F, S]): FoldM[M, S, A] = + new FoldM[M, S, A]( Machines.foldLayeredM[M, F, S, A]( s => M.pure(Right(P.project(s))), (s, fs, out) => algM(s, Machines.rebuildLayer[F, S, A](fs, out)), - ), - algM, + ) ) /** Effectful anamorphism — the coalgebra (the layer producer) runs in `M` (`Seed => M[F[Seed]]`, - * the arbo `GetSellOptions` shape: fetching children is effectful). Returns the [[AnaM]] - * citizen; consume via `.run`, fuse via `.andThen(cataM(...))`. + * the arbo `GetSellOptions` shape: fetching children is effectful). Returns the [[zoo.FoldM]] + * citizen; consume via `.run`; `anaM.andThen(cataM)` is the materialising effectful hylo, + * `hyloM` the fused one. */ def anaM[M[_], F[_], Seed, S]( coalgM: Seed => M[F[Seed]] - )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): AnaM[M, F, Seed, S] = - new AnaM[M, F, Seed, S]( + )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): FoldM[M, Seed, S] = + new FoldM[M, Seed, S]( Machines.foldLayeredM[M, F, Seed, S]( seed => M.map(coalgM(seed))(Right(_)), (_, fSeed, out) => M.pure(E.embed(Machines.rebuildLayer[F, Seed, S](fSeed, out))), - ), - coalgM, + ) ) /** Effectful hylomorphism — the always-fused M spelling (what the D6 `eoHyloM` bench row runs): 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 deleted file mode 100644 index ae41bfa1..00000000 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala +++ /dev/null @@ -1,44 +0,0 @@ -package dev.constructive.eo -package schemes -package zoo - -import cats.Traverse - -import data.Direct -import optics.{Getter, Optic} - -/** Unfold-scheme citizen: Review-shaped, carrying the coalgebra + instances for fusion. - * - * See [[Cata]] for the general citizen contract (widening hazard, encoding rationale). - */ -final class Ana[F[_], Seed, S] private[schemes] ( - val reverseGet: Seed => S, - private[schemes] val coalg: Seed => F[Seed], -)(using - private[schemes] val F: Traverse[F], - private[schemes] val E: Embed[F, S], -) extends Optic[Unit, S, Unit, Seed, Direct]: - type X = Nothing - - def to(u: Unit): Direct[X, Unit] = Direct(u) - def from(d: Direct[X, Seed]): S = reverseGet(d.value) - - /** View as a plain [[optics.Review]] — re-enters Review's fused composition fast paths. NOTE: the - * widened value loses the fused `cross` below (the widening hazard). - */ - def asReview: optics.Review[S, Seed] = optics.Review(reverseGet) - - /** THE fusion seam — deforestation as composition, on the seam core names for it (`Optic.cross`'s - * motivating case is `ana.cross(cata)`). One single-pass machine: each node is built once, - * folded immediately, and released as the fold ascends — **no full-tree retention, no second - * traversal** (the materializing spelling builds all of `S`, then folds it). The algebra is - * node-supplied, so per-node construction is semantically required; a node-*blind* computation - * should use `Schemes.hylo(coalg, alg)`, the zero-`S` spelling. - * - * Resolution: strictly more specific than the generic trait `cross`, so concrete-typed - * `ana(c).cross(cata(a))` lands here (pinned by an ascription test); widened operands fall back - * to the generic, materializing route — extensionally equal, allocation-different. - */ - def cross[A](inner: Cata[F, S, A]): Getter[Seed, A] = - val machine: Seed => (S, A) = Machines.fusedPairedFold(coalg, inner.alg)(using F, E) - Getter[Seed, A](seed => machine(seed)._2) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/AnaM.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/AnaM.scala deleted file mode 100644 index 7947a2b3..00000000 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/AnaM.scala +++ /dev/null @@ -1,27 +0,0 @@ -package dev.constructive.eo -package schemes -package zoo - -import cats.{Monad, Traverse} - -/** Effectful unfold-scheme citizen: `run: Seed => M[S]`, carrying the coalgebra + instances for - * fusion. - */ -final class AnaM[M[_], F[_], Seed, S] private[schemes] ( - run: Seed => M[S], - private[schemes] val coalgM: Seed => M[F[Seed]], -)(using - private[schemes] val M: Monad[M], - private[schemes] val F: Traverse[F], - private[schemes] val E: Embed[F, S], -) extends FoldM[M, Seed, S](run): - - /** The fused M seam — here `andThen` genuinely is the focus seam (`Forget[M]` Kleisli). One - * single-pass machine in `M` (the paired fold lifted through `tailRecM`): each node built once, - * folded immediately — no `M[S]` materialization of the whole structure. Requires the concrete - * types; widened operands fall back to the generic materializing `andThen`. - */ - def andThen[A](inner: CataM[M, F, S, A]): FoldM[M, Seed, A] = - val machine: Seed => M[(S, A)] = - Machines.fusedPairedFoldM(coalgM, inner.algM)(using M, F, E) - new FoldM[M, Seed, A](seed => M.map(machine(seed))(_._2)) 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 deleted file mode 100644 index 1329f8e7..00000000 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala +++ /dev/null @@ -1,35 +0,0 @@ -package dev.constructive.eo -package schemes -package zoo - -import data.Direct -import optics.{Getter, Optic} - -/** Fold-scheme citizen: Getter-shaped, carrying the node-supplied algebra for fusion. - * - * Concrete scheme citizens — `cata`/`ana` return these instead of bare `Getter`/`Review` so - * composition can **fuse**: the classes carry their (co)algebra and instances as data, and the - * fused `cross` overload on [[Ana]] resolves on the concrete types. - * - * They extend the open `Optic` trait directly (`Getter`/`Review` are `final` in core — the - * perf-pinned encoding stays untouched): full generic composition via the trait members, plus - * `.get` / `.reverseGet` as stored fields, the use-site-friendly shape. - * - * Widening hazard, documented: binding a `Cata` (or `Ana`) to a wider type loses the fused `cross` - * overload — the generic trait `cross` still typechecks and is extensionally equal, but - * materializes the full intermediate structure. `Schemes.hylo(coalg, alg)` stays the always-fused - * spelling. - */ -final class Cata[F[_], S, A] private[schemes] ( - val get: S => A, - private[schemes] val alg: (S, F[A]) => A, -) extends Optic[S, Unit, A, Unit, Direct]: - type X = Nothing - - def to(s: S): Direct[X, A] = Direct(get(s)) - def from(d: Direct[X, Unit]): Unit = () - - /** View as a plain [[Getter]] — re-enters Getter's fused composition fast paths (and resolves the - * read-compose overload tie an unascribed `getter.andThen(cata(...))` can hit). - */ - def asGetter: Getter[S, A] = Getter(get) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/CataM.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/CataM.scala deleted file mode 100644 index 046b082b..00000000 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/CataM.scala +++ /dev/null @@ -1,9 +0,0 @@ -package dev.constructive.eo -package schemes -package zoo - -/** Effectful fold-scheme citizen: carries its algebra for fusion. */ -final class CataM[M[_], F[_], S, A] private[schemes] ( - run: S => M[A], - private[schemes] val algM: (S, F[A]) => M[A], -) extends FoldM[M, S, A](run) 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 index fcfc6d87..9530593e 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala @@ -2,11 +2,12 @@ package dev.constructive.eo package schemes package zoo +import cats.Monad + import data.{Forget, ForgetK} import optics.Optic -/** Generic effectful-fold citizen: `run: S => M[A]`. What `hyloM` and the fused - * `AnaM.andThen(CataM)` return. +/** Generic effectful-fold citizen: `run: S => M[A]`. What `cataM` / `anaM` / `hyloM` return. * * Effectful scheme citizens — the M-generic drivers' return types. Computational steps evolve in a * `Monad[M]` (the arbo `Calculator` shape: fetching a node's children is effectful), and the @@ -27,17 +28,26 @@ import optics.Optic * own fresh mutable state (the frame deque is allocated inside the `M`, not before it). Concurrent * forcing of a single `M[A]` value remains unsupported; each `run(s)` call is independent. * - * Widening hazard (the M-path mirror of `Ana.cross`'s): a widened `AnaM` still typechecks through - * the generic trait `andThen` via `assocForgetMonad` — extensionally equal but MATERIALIZING - * (`M[S]` built, then folded). `Schemes.hyloM` stays the always-fused M spelling; the fused member - * on [[AnaM]] requires the concrete types. + * Composition: `anaM.andThen(cataM)` composes two `Forget[M]` citizens at the focus seam (via + * `assocForgetMonad`) into the materialising effectful hylo (`M[S]` built, then folded); + * `Schemes.hyloM` is the fused one-pass spelling (no `M[S]`). This mirrors the pure side exactly, + * where `ana.andThen(cata)` is the materialising hylo and `Schemes.hylo` the fused one. * - * `FoldM` is `open` (not `sealed`) so that [[CataM]] and [[AnaM]] — which live in the `zoo` - * subpackage — can extend it. Users may also wrap their own `S => M[A]` as a `FoldM` citizen; the - * constructor is public. + * `FoldM` is a concrete, public citizen: `cataM` / `anaM` / `hyloM` all return it, and users may + * wrap their own `S => M[A]` as one. */ class FoldM[M[_], S, A](val run: S => M[A]) extends Optic[S, Unit, A, Unit, Forget[M]]: type X = Nothing def to(s: S): Forget[M][X, A] = ForgetK(run(s)) def from(d: Forget[M][X, Unit]): Unit = () + + /** The focus-seam composition of two effectful reads — the **materialising** effectful hylo: + * `run` this `FoldM` fully to `M[A]`, then Kleisli-chain into `inner` (`M.flatMap`). A concrete + * same-type member (mirroring the pure `Getter.andThen`), so it is strictly more specific than + * the generic `Optic.andThen` overloads — `anaM.andThen(cataM)` resolves here and stays a + * `FoldM`, no ascription needed. Requires `Monad[M]` (the effect's bind); `Schemes.hyloM` is the + * fused one-pass spelling that builds no intermediate `M[A]`. + */ + def andThen[C](inner: FoldM[M, A, C])(using M: Monad[M]): FoldM[M, S, C] = + new FoldM[M, S, C](s => M.flatMap(run(s))(inner.run)) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala index 5d7963fb..da11b075 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala @@ -6,14 +6,14 @@ import org.specs2.mutable.Specification import optics.Getter import schemes.samples.{Bin, BinF} -/** The fusion seam: `ana(c).cross(cata(a))`. +/** The hylo law as composition: `ana.andThen(cata) == hylo`. * - * - Resolution pin: the ascription `: Getter[Seed, A]` compiles only if the FUSED overload on - * the concrete `Ana` wins (the generic trait `cross` returns a bare `Optic`, not a - * `Getter`) — the matrix-spec-style proof the overload set resolves as designed. - * - Fusion law: fused cross == the materializing composition (all algebras, extensional), and - * == `hylo` for algebras that read the node only through the seed↔`embed(coalg(seed))` - * correspondence (here: a pure algebra typed at both nodes and seeds). + * Both `ana` and `cata` are forward `Getter`s (`Getter[Seed, S]` and `Getter[S, A]`), so they + * compose at the focus seam with the core fused `Getter.andThen` — no `cross`, no clone classes. + * - `ana.andThen(cata)` is the **materialising** hylo: it builds the full `S`, then folds it. + * - `Schemes.hylo(coalg, alg)` is the **fused** hylo: one pass, no intermediate `S`. + * - They agree on every seed (the hylo law) for a pure algebra; for a node-reading para algebra + * only under the seed↔`embed(coalg(seed))` correspondence. */ class FusionSpec extends Specification: @@ -34,38 +34,33 @@ class FusionSpec extends Specification: case BinF.LeafF(n) => n case BinF.BranchF(l, r) => l + r - "ana(c).cross(cata(a)) resolves to the FUSED overload (ascription pin)" >> { - val fused: Getter[Int, Int] = Schemes.ana[BinF, Int, Bin](expand).cross(Schemes.cata(sumAlg)) - fused.get(6) === 6 // six leaves of weight 1 + "ana.andThen(cata) composes via the core fused Getter.andThen (no cross)" >> { + val hylo: Getter[Int, Int] = Schemes.ana[BinF, Int, Bin](expand).andThen(Schemes.cata(sumAlg)) + hylo.get(6) === 6 // six leaves of weight 1 } - "fused cross == the materializing composition (node-supplied algebra)" >> { + "ana.andThen(cata) == cata.get ∘ ana.get (the materialising composition)" >> { val seeds = List(1, 2, 3, 5, 8, 13) val ana = Schemes.ana[BinF, Int, Bin](expand) val cata = Schemes.cata(sumAlg) - val fused = ana.cross(cata) - seeds.map(fused.get) === seeds.map(s => cata.get(ana.reverseGet(s))) + val composed = ana.andThen(cata) + seeds.map(composed.get) === seeds.map(s => cata.get(ana.get(s))) } - "fused cross == hylo for a pure algebra (the hylo law under the correspondence)" >> { + "ana.andThen(cata) == hylo for a pure algebra (the hylo law)" >> { val seeds = List(1, 2, 3, 5, 8, 13) - val fused = Schemes.ana[BinF, Int, Bin](expand).cross(Schemes.cata(sumAlg)) - seeds.map(fused.get) === seeds.map(Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get) + val composed = Schemes.ana[BinF, Int, Bin](expand).andThen(Schemes.cata(sumAlg)) + seeds.map(composed.get) === seeds.map(Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get) } - // Depth bar: stack-safety needs >> the ~10k-frame JVM stack; 200k proves the machine - // (the 10^6 SPACE bar is carried by the ana/apo sweeps — this suite's fused machine - // additionally retains the (S, A) pairs, and the suites share one test JVM). - "fused cross is stack-safe on a 200k-deep spine (single pass)" >> { - val Deep = 200_000 - def spineCoalg(n: Int): BinF[Int] = + "the fused hylo builds no intermediate Bin and is stack-safe at depth 10^6" >> { + val Deep = 1_000_000 + def spine(n: Int): BinF[Int] = if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) - def leafOrSpine(n: Int): BinF[Int] = - if n < 0 then BinF.LeafF(0) else spineCoalg(n) - val depthAlg: (Bin, BinF[Int]) => Int = (_, fa) => + def leafOrSpine(n: Int): BinF[Int] = if n < 0 then BinF.LeafF(0) else spine(n) + val depthAlg: (Int, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 0 case BinF.BranchF(l, r) => 1 + math.max(l, r) - val fused = Schemes.ana[BinF, Int, Bin](leafOrSpine).cross(Schemes.cata(depthAlg)) - (fused.get(Deep) == Deep) must beTrue + (Schemes.hylo[BinF, Int, Int](leafOrSpine, depthAlg).get(Deep) == Deep) must beTrue } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala index c7dee650..068fa1e8 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala @@ -127,8 +127,8 @@ class GatherScatterLawsSpec extends Specification: "the generic decoration route agrees with the direct overload on ana" >> { def expand(n: Int): BinF[Int] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) - Schemes.ana[BinF, Int, Int, Bin](Scatter.ana[BinF, Int])(expand).reverseGet(5) === - Schemes.ana[BinF, Int, Bin](expand).reverseGet(5) + Schemes.ana[BinF, Int, Int, Bin](Scatter.ana[BinF, Int])(expand).get(5) === + Schemes.ana[BinF, Int, Bin](expand).get(5) } "histo through Gather.histo: heads-only course-of-value == cata" >> { @@ -148,7 +148,7 @@ class GatherScatterLawsSpec extends Specification: else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) val built = Schemes .ana[BinF, Int, Coattr[BinF, Int], Bin](Scatter.futu[BinF, Int])(coalg) - .reverseGet(3) + .get(3) built === Bin.Branch(Bin.Leaf(3), Bin.Branch(Bin.Leaf(2), Bin.Leaf(1))) } @@ -174,8 +174,8 @@ class GatherScatterLawsSpec extends Specification: def coalg(n: Int): BinF[Coattr[BinF, Int]] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) - Schemes.futu[BinF, Int, Bin](coalg).reverseGet(4) === - Schemes.ana[BinF, Int, Coattr[BinF, Int], Bin](Scatter.futu[BinF, Int])(coalg).reverseGet(4) + Schemes.futu[BinF, Int, Bin](coalg).get(4) === + Schemes.ana[BinF, Int, Coattr[BinF, Int], Bin](Scatter.futu[BinF, Int])(coalg).get(4) } // ----- generic-route end-to-end pins -------------------------------------- @@ -205,9 +205,9 @@ class GatherScatterLawsSpec extends Specification: def coalg(n: Int): BinF[Either[Bin, Int]] = if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Left(Bin.Leaf(n)), Right(n - 1)) - val nativeResult = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(2) + val nativeResult = Schemes.apo[BinF, Int, Bin](coalg).get(2) val genericResult = - Schemes.ana[BinF, Int, Either[Bin, Int], Bin](apoScatter[BinF, Bin, Int])(coalg).reverseGet(2) + Schemes.ana[BinF, Int, Either[Bin, Int], Bin](apoScatter[BinF, Bin, Int])(coalg).get(2) // Both produce the same tree by value; use == not eq (generic route REBUILDS the graft). nativeResult === genericResult } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala index a79ab088..9bf54a8d 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala @@ -75,11 +75,11 @@ class SchemesConcurrencySpec extends Specification: ) .get(seed) val expectedHylo = - Schemes.cata(sumAlg).get(Schemes.ana[BinF, Int, Bin](expand).reverseGet(seed)) + Schemes.cata(sumAlg).get(Schemes.ana[BinF, Int, Bin](expand).get(seed)) val hyloOk = hyloResult == expectedHylo // (c) ana builds a per-task Bin from a per-task seed, cata folds it back - val built: Bin = Schemes.ana[BinF, Int, Bin](expand).reverseGet(seed) + val built: Bin = Schemes.ana[BinF, Int, Bin](expand).get(seed) val anaOk = Schemes.cata(sumAlg).get(built) == expectedHylo // (d) para on the shared tree ignoring subterms == cata @@ -92,7 +92,7 @@ class SchemesConcurrencySpec extends Specification: val futuCoalg: 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)) - val futuBuilt: Bin = Schemes.futu[BinF, Int, Bin](futuCoalg).reverseGet(seed) + val futuBuilt: Bin = Schemes.futu[BinF, Int, Bin](futuCoalg).get(seed) val futuOk = Schemes.cata(sumAlg).get(futuBuilt) == expectedHylo // (g) cataM[Eval].run forced per task diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala index 05389609..a04adc13 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala @@ -14,7 +14,7 @@ import schemes.samples.{Bin, BinF, Rose, RoseF} * * - '''Project/Embed coherence''' — the hand-written `S`↔`F` correspondence is not * compiler-checked, so its two round-trip laws are property-tested here. - * - '''Hylo law (pure flavor)''' — `hylo == ana.cross(cata)` holds *generically* only when the + * - '''Hylo law (pure flavor)''' — `hylo == ana.andThen(cata)` holds *generically* only when the * algebra ignores its node argument (a pure `F[A] => A` fold). Tested via `forAll`. * - '''Hylo law (para flavor)''' — for a node-reading algebra, `hylo` threads the *seed* while * the materializing `cata` threads the rebuilt `S`, so the two coincide only under the @@ -95,13 +95,13 @@ class SchemesLawsSpec extends Specification with ScalaCheck: case BinF.BranchF(l, r) => l + r } - "hylo == ana.cross(cata) for a PURE algebra (the hylo law)" >> { + "hylo == ana.andThen(cata) for a PURE algebra (the hylo law)" >> { forAll(Gen.choose(0, 12)) { (seed: Int) => val fused = Schemes.hylo[BinF, Int, Int](coalg, (_, fa) => pureSum(fa)).get(seed) val materializing = Schemes .ana[BinF, Int, Bin](coalg) - .cross(Schemes.cata[BinF, Bin, Int]((_, fa) => pureSum(fa))) + .andThen(Schemes.cata[BinF, Bin, Int]((_, fa) => pureSum(fa))) .get(seed) fused == materializing } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala index b4250d7e..64869241 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala @@ -12,8 +12,8 @@ import zoo.* * * - Fast-path agreement laws — `M = Id` on the lifted machine == the pure citizens on the hybrid * machine (a real cross-architecture pin: there is NO Id special-case). - * - The fused `AnaM.andThen(CataM)` == the run-then-run composition (extensional), resolved to - * the concrete member (ascription pin). + * - The materialising `anaM.andThen(cataM)` == the run-then-run composition (extensional), + * resolved to the concrete `FoldM` member (Kleisli `flatMap`); `hyloM` is the fused spelling. * - Stack-safety on `Eval` (200k spine; safety rides on M's `tailRecM` — tested, not asserted). * - The linear-M boundary: `List` (a branching M) is documented UNSUPPORTED — the machine's * mutable state is shared across branches; this test pins that the result is NOT the branching @@ -49,9 +49,9 @@ class SchemesMSpec extends Specification: Schemes.cata(sumAlg).get(tree) } - "anaM[Id].run == ana.reverseGet" >> { + "anaM[Id].run == ana.get" >> { Schemes.anaM[Id, BinF, Int, Bin](n => expand(n)).run(6) === - Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) + Schemes.ana[BinF, Int, Bin](expand).get(6) } "hyloM[Id].run == hylo.get" >> { @@ -61,10 +61,10 @@ class SchemesMSpec extends Specification: // ----- fusion --------------------------------------------------------------- - "AnaM.andThen(CataM) resolves to the fused member (ascription pin) and == run∘run" >> { + "anaM.andThen(cataM) resolves to the concrete materialising member and == run∘run" >> { val anaM = Schemes.anaM[Id, BinF, Int, Bin](n => expand(n)) val cataM = Schemes.cataM[Id, BinF, Bin, Int]((s, fa) => sumAlg(s, fa)) - val fused: FoldM[Id, Int, Int] = anaM.andThen(cataM) // generic andThen returns a bare Optic + val fused: FoldM[Id, Int, Int] = anaM.andThen(cataM) // concrete FoldM.andThen (Kleisli) List(1, 2, 3, 5, 8, 13).map(fused.run) === List(1, 2, 3, 5, 8, 13).map(seed => cataM.run(anaM.run(seed))) } 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 9fa3e0a9..101184d6 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -9,10 +9,9 @@ import org.specs2.mutable.Specification import data.Forget import data.Forget.given import optics.{Getter, Lens, Optic} -import optics.Optic.* // get, andThen, cross, foldMap +import optics.Optic.* // get, andThen, foldMap import schemes.samples.{Bin, BinF, Rose, RoseF} -import zoo.* /** Behaviour spec for the typed pattern-functor schemes (`cata` / `ana` / `hylo`) and `fLayer`. * Companion law/coherence checks live in `SchemesLawsSpec`. @@ -40,7 +39,7 @@ class SchemesSpec extends Specification: // ----- cata (typed fold) ----- "cata folds a Bin to a value through F's named constructors" >> { - val sumG: Cata[BinF, Bin, Int] = Schemes.cata(sumLeaves) + val sumG: Getter[Bin, Int] = Schemes.cata(sumLeaves) (sumG.get(tree) == 6) must beTrue } @@ -91,7 +90,7 @@ class SchemesSpec extends Specification: "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) + val built: Bin = Schemes.ana[BinF, Int, Bin](spine).get(3) // seed 3 -> Branch(Branch(Branch(Leaf 1, Leaf 1), Leaf 1), Leaf 1): 4 leaves of 1, depth 3 (Schemes.cata(sumLeaves).get(built) == 4).and( Schemes @@ -104,9 +103,9 @@ class SchemesSpec extends Specification: ) } - "ana.cross(cata) is the materializing hylo (builds the Bin, then folds)" >> { + "ana.andThen(cata) is the materializing hylo (builds the Bin, then folds)" >> { val spine: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(n - 1, -1) - val refold = Schemes.ana[BinF, Int, Bin](spine).cross(Schemes.cata(sumLeaves)) + val refold = Schemes.ana[BinF, Int, Bin](spine).andThen(Schemes.cata(sumLeaves)) (refold.get(3) == 4) must beTrue } @@ -202,7 +201,7 @@ class SchemesSpec extends Specification: "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) + val deep: Bin = Schemes.ana[BinF, Int, Bin](coalg).get(Deep) val depth: (Bin, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 0 @@ -234,7 +233,7 @@ class SchemesSpec extends Specification: if d <= 0 then RoseF(0, Nil) else RoseF(d, (d - 1) :: List.fill(Width)(-1)) val countNodes: (Rose, RoseF[Int]) => Int = (_, fr) => 1 + fr.kids.sum - val built: Rose = Schemes.ana[RoseF, Int, Rose](coalg).reverseGet(DeepRose) + val built: Rose = Schemes.ana[RoseF, Int, Rose](coalg).get(DeepRose) val expected = (DeepRose + 1) + DeepRose * Width (Schemes.cata(countNodes).get(built) == expected) must beTrue } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala index e91edec4..974b8e7d 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala @@ -62,8 +62,8 @@ class SchemesZooSpec extends Specification: if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) val viaApo = Schemes .apo[BinF, Int, Bin](n => BinF.traverse.map(expand(n))(Right(_))) - .reverseGet(6) - viaApo === Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) + .get(6) + viaApo === Schemes.ana[BinF, Int, Bin](expand).get(6) } "heads-only histo == cata" >> { @@ -80,8 +80,8 @@ class SchemesZooSpec extends Specification: 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) + .get(6) + viaFutu === Schemes.ana[BinF, Int, Bin](expand).get(6) } // ----- the graft law (O(1), by reference) ---------------------------------- @@ -93,7 +93,7 @@ class SchemesZooSpec extends Specification: if n <= 0 then BinF.LeafF(7) else if n == 1 then BinF.BranchF(Left(grafted), Right(0)) else BinF.BranchF(Right(n - 1), Right(n - 1)) - val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(1) + val built = Schemes.apo[BinF, Int, Bin](coalg).get(1) val graftSlot = built match case Bin.Branch(g, _) => g case other => other @@ -108,7 +108,7 @@ class SchemesZooSpec extends Specification: if n <= 0 then BinF.LeafF(0) else if n == 1 then BinF.BranchF(Left(grafted), Right(0)) else BinF.BranchF(Right(n - 1), Left(Bin.Leaf(0))) - val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(GraftDepth) + val built = Schemes.apo[BinF, Int, Bin](coalg).get(GraftDepth) // Navigate left spine to find the graft slot (iterative — safe at any depth) var cursor: Bin = built var steps = GraftDepth - 1 @@ -165,7 +165,7 @@ class SchemesZooSpec extends Specification: "apo is stack/space-safe building a 10^6-deep Bin" >> { def coalg(n: Int): BinF[Either[Bin, Int]] = if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Right(n - 1), Left(Bin.Leaf(0))) - val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(Deep) + val built = Schemes.apo[BinF, Int, Bin](coalg).get(Deep) val depth: (Bin, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 0 @@ -187,7 +187,7 @@ class SchemesZooSpec extends Specification: if n <= 0 then BinF.LeafF(0) else if n % 2 == 0 then BinF.BranchF(Coattr.Roll(BinF.LeafF(0)), Coattr.Pure(n - 1)) else BinF.BranchF(Coattr.Pure(n - 1), Coattr.Roll(BinF.LeafF(0))) - val built = Schemes.futu[BinF, Int, Bin](coalg).reverseGet(Deep) + val built = Schemes.futu[BinF, Int, Bin](coalg).get(Deep) val size: (Bin, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 1 diff --git a/site/docs/schemes.md b/site/docs/schemes.md index 9b0e6ef9..6cbf2ead 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -7,8 +7,8 @@ constructors** (compile-time arity safety, no positional indexing): | Scheme | Optic | Direction | |--------|-------|-----------| -| `cata` | `Cata` (Getter-shaped) | fold an existing `S` to an `A` | -| `ana` | `Ana` (Review-shaped) | build an `S` from a seed | +| `cata` | `Getter[S, A]` | fold an existing `S` to an `A` | +| `ana` | `Getter[Seed, S]` | 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` | `Forget[M]`-carried | effectful steps in a `Monad[M]` | @@ -34,7 +34,6 @@ You write three things: the functor `F`, its `cats.Traverse`, and a `Basis` (`Pr ```scala mdoc:silent import cats.{Applicative, Eval, Traverse} import dev.constructive.eo.schemes.Basis -import dev.constructive.eo.schemes.zoo.Cata // A binary tree… enum Bin: @@ -70,7 +69,7 @@ val binTree: Bin = Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) typed `BinF[A]`** — `l` and `r` are `A`, by name, no positional indexing: ```scala mdoc:silent -val sumLeavesF: Cata[BinF, Bin, Int] = +val sumLeavesF: Getter[Bin, Int] = Schemes.cata[BinF, Bin, Int] { (_, folded) => folded match case BinF.LeafF(n) => n @@ -104,14 +103,14 @@ val countLeavesF: Getter[Int, Int] = ``` ```scala mdoc -sumLeavesF.get(buildBin.reverseGet(3)) // 4 unit leaves +sumLeavesF.get(buildBin.get(3)) // 4 unit leaves countLeavesF.get(3) // same count, fused — no Bin materialised countLeavesF.get(1000000) // stack-safe: the heap machine, O(depth) heap ``` -`cata`/`hylo` are Getter-shaped and `ana` is Review-shaped, so -they compose with the rest of the optic algebra via `andThen` and `cross` (the materializing -`ana(…).cross(cata(…))` equals the fused `hylo` for a pure algebra — the hylo law). They run on +`cata`, `ana`, and `hylo` are all **forward `Getter`s** (`ana` builds, reading `Seed => S`), so +they compose with the rest of the optic algebra via `andThen` (the materializing +`ana(…).andThen(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 @@ -139,7 +138,7 @@ val treeL = Lens[Inner, Bin](_.tree, (i, t) => i.copy(tree = t)) val deepTree = innerL.andThen(treeL) // Lens[Doc, Bin] — lens composition // wrap the composed lens's read in a Getter, then andThen the scheme → reusable Getter[Doc, Int] -val docLeafSum = Getter[Doc, Bin](deepTree.get).andThen(sumLeavesF.asGetter) +val docLeafSum = Getter[Doc, Bin](deepTree.get).andThen(sumLeavesF) val record = Doc(1, Inner("x", binTree)) ``` @@ -158,9 +157,9 @@ primarily the proof that a typed `F` is an optic carrier; the recursive schemes ## The zoo: para / apo / histo / futu The decorated schemes are **one sum/product symmetry**, and eo ships it as a vocabulary of -*decoration optics* (the `Decor` family, worn on the new `BiAffine` carrier — see below): +*decoration optics* (the `Gather`/`Scatter` family, worn on the new `BiAffine` carrier — see below): -| scheme | decoration | shape | `Decor` value | +| scheme | decoration | shape | `Gather`/`Scatter` value | |---|---|---|---| | cata / ana | none | — | `Gather.cata` / `Scatter.ana` | | **para** | child slots carry the original subterms | product | (native only) | @@ -200,7 +199,7 @@ val patched = Schemes.apo[BinF, Int, Bin] { n => ``` ```scala mdoc -patched.reverseGet(2) +patched.get(2) ``` `histo` gives the algebra each child's **entire decorated history** (`Attr[F, A]`: the result @@ -232,13 +231,13 @@ val twoAtATime = Schemes.futu[BinF, Int, Bin] { n => } ``` -### Fusion is composition: `cross` +### Composition and fusion: `andThen` vs `hylo` -`ana`/`cata` return concrete citizens (`Ana`/`Cata`) carrying their (co)algebras, so the -build-output→read-input composition — the seam core's `Optic.cross` names — **fuses**: one -single-pass machine, each node built once and folded immediately, no full-tree retention and no -second traversal. (`hylo` remains the zero-`S` spelling for seed-typed algebras; binding an -`Ana` to a wider type falls back to the generic, materializing `cross` — extensionally equal.) +Because `ana` and `cata` are both forward `Getter`s, the unfold-then-fold refold is just their +`andThen` at the focus seam — no `cross`, no clone classes. `ana(…).andThen(cata(…))` is the +**materialising** 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 zooExpand: Int => BinF[Int] = n => @@ -246,14 +245,14 @@ val zooExpand: Int => BinF[Int] = n => val zooSum: (Bin, BinF[Int]) => Int = (_, fa) => fa match { case BinF.LeafF(n) => n; case BinF.BranchF(l, r) => l + r } -val fusedLeafSum = Schemes.ana[BinF, Int, Bin](zooExpand).cross(Schemes.cata(zooSum)) +val fusedLeafSum = Schemes.ana[BinF, Int, Bin](zooExpand).andThen(Schemes.cata(zooSum)) ``` ```scala mdoc fusedLeafSum.get(6) ``` -### Write your own decoration: zygo as a `Decor` value +### Write your own decoration: zygo as a `Gather` value The generality that droste exposes as `gcata`/`gana` lives here as the **public `Gather`/`Scatter` decoration optics**: a decoration is an optic over the `BiAffine` carrier (fold side: `from` = *gather*; unfold side: @@ -289,8 +288,9 @@ When producing a layer is itself effectful — fetching a node's children from a `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). Results are `Forget[M]`-carried citizens consumed via `.run`, and -`AnaM.andThen(CataM)` fuses (there `andThen` genuinely is the focus seam): +unsupported). Results are `Forget[M]`-carried `FoldM` citizens consumed via `.run`; +`anaM.andThen(cataM)` is the materialising effectful hylo (Kleisli `flatMap` — `M[S]` built, then +folded), mirroring the pure side, with `Schemes.hyloM` the fused one-pass spelling: ```scala mdoc:silent import cats.data.State @@ -316,7 +316,8 @@ The decoration optics' carrier is new in core: **`BiAffine`** — `Affine`'s dat seam. `Step(context, focus)` keeps going; `Done(payload)` means "this slot is already finished — do not call the coalgebra" (apo grafts a finished subtree, futu unrolls a prebuilt layer). Its laws are the graft-finality and round-trip equations in `cats-eo-laws`. Composition here is -scoped to the shipped seams — the fused `cross`, the M-path `andThen`, and the generic drivers; +scoped to the shipped seams — the `Getter.andThen` refold (pure) and `FoldM.andThen` (M-path), +plus the fused `hylo`/`hyloM` drivers; BiAffine's full composition-matrix row is follow-up work, as are the elgot/coelgot decorations (the answer-level short-circuit, which the M machine's internals are already shaped for). From 0456c31b7e9aa1f5d21e5d1df57d98b654b87c78 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sat, 13 Jun 2026 12:22:47 +0200 Subject: [PATCH 30/61] style(benchmarks): apply scalafmt to drifted benchmark sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure formatting — scalafmtAll-driven reflow of pre-existing drift in benchmark sources (not in the root aggregate, so untouched by the scalafmtCheckAll CI gate). No behavioural change. Co-Authored-By: Claude Fable 5 --- .../dev/constructive/eo/bench/fixture/SchemesFixtures.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 90af16ef..50fca1e2 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 @@ -77,7 +77,8 @@ object SchemesFixtures: case BinF.LeafF(v) => v case BinF.NodeF(l, r) => l + r - /** Typed coalgebra (the single fused `Seed => F[Seed]` shape) — builds the perfect binary tree. */ + /** Typed coalgebra (the single fused `Seed => F[Seed]` shape) — builds the perfect binary tree. + */ val eoTypedCoalg: Int => BinF[Int] = d => if d <= 0 then BinF.LeafF(1) else BinF.NodeF(d - 1, d - 1) From 2343f946e6815821c9ae710176aa9eb78a2c631f Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sat, 13 Jun 2026 12:42:54 +0200 Subject: [PATCH 31/61] =?UTF-8?q?fix(schemes)!:=20ana=20is=20a=20Review,?= =?UTF-8?q?=20not=20a=20Getter=20=E2=80=94=20restore=20the=20cata/ana=20du?= =?UTF-8?q?ality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the prior commit (6406304), which wrongly made pure `ana`/`apo`/`futu` forward `Getter`s. cata and ana are dual; Getter and Review are dual (reverse of each other). So `cata : Getter[S, A]` (fold/read) ⟹ `ana : Review[S, Seed]` (unfold/build) — its build-only mirror. The "fucked up" smell that started this was the `Cata`/`Ana`/`CataM`/`AnaM` clone classes with `asGetter`/`asReview`, NOT the directions. Fix: `cata` returns the real core `Getter[S, A]`, `ana`/`apo`/`futu` the real core `Review[S, Seed]` — no clones. The materialising refold is `ana.cross(cata)`, exactly the build-output⇄read-input seam `Optic.cross` is documented for; the fused spelling stays `Schemes.hylo`. M rung: the M effect only fits `Forget[M]`'s Kleisli read slot (`Seed => M[S]`), and there is no carrier for "build with effectful output", so both `cataM` and `anaM` stay `FoldM` Kleisli arrows (`anaM` is semantically a build). The read/build duality collapses there; composition is `anaM.andThen(cataM)` (the concrete `FoldM.andThen`, Kleisli flatMap), documented as a deliberate asymmetry. Empirical (-prof gc): ana.cross(cata) 885,577 B/op == manual cata.get(ana.reverseGet()) 885,577; fused hylo 361,384 (unchanged pin). 74/74 schemes tests green; full root test green; mdoc 0 errors. Co-Authored-By: Claude Fable 5 --- .../constructive/eo/bench/SchemesBench.scala | 28 +++++----- ...06-11-001-feat-biaffine-scheme-zoo-plan.md | 27 +++++----- .../dev/constructive/eo/schemes/Schemes.scala | 52 +++++++++++-------- .../constructive/eo/schemes/zoo/FoldM.scala | 9 ++-- .../constructive/eo/schemes/FusionSpec.scala | 26 +++++----- .../eo/schemes/GatherScatterLawsSpec.scala | 14 ++--- .../eo/schemes/SchemesConcurrencySpec.scala | 6 +-- .../eo/schemes/SchemesLawsSpec.scala | 6 +-- .../eo/schemes/SchemesMSpec.scala | 4 +- .../constructive/eo/schemes/SchemesSpec.scala | 12 ++--- .../eo/schemes/SchemesZooSpec.scala | 16 +++--- site/docs/schemes.md | 35 +++++++------ 12 files changed, 127 insertions(+), 108 deletions(-) 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 3da419b7..41b0ca4b 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -47,7 +47,7 @@ class SchemesBench extends JmhDefaults: // 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 eoAnaG = Schemes.ana[BinF, Int, Bin](eoTypedCoalg) // Getter[Int, Bin] + 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) @@ -64,7 +64,7 @@ class SchemesBench extends JmhDefaults: @Benchmark def handHylo: Int = SchemesFixtures.handHylo(Depth) // ----- ana: build the tree from a seed (materializing) --------------------- - @Benchmark def eoAna: Bin = eoAnaG.get(Depth) + @Benchmark def eoAna: Bin = eoAnaR.reverseGet(Depth) @Benchmark def drosteAna: Fix[BinF] = drosteAnaF(Depth) @Benchmark def handAna: Bin = handBuild(Depth) @@ -72,20 +72,20 @@ class SchemesBench extends JmhDefaults: val eoParaG = Schemes.para[BinF, Bin, Int](eoParaAlg) val drosteParaFn: Fix[BinF] => Int = scheme.zoo.para(drosteParaAlg) - val eoApoG = Schemes.apo[BinF, Int, Bin](eoApoCoalg) + 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 eoFutuG = Schemes.futu[BinF, Int, Bin](eoFutuCoalg) + 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 = eoApoG.get(Depth) + @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 = eoFutuG.get(Depth) + @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 @@ -95,7 +95,7 @@ class SchemesBench extends JmhDefaults: // guarantee; the O(graft) re-walk contrast applies to the GENERIC distApo // route (distApo, a law fixture only), not to droste.zoo.apo. - val eoApoGraftG = Schemes.apo[BinF, Int, Bin] { d => + val eoApoGraftR = Schemes.apo[BinF, Int, Bin] { d => if d == 0 then BinF.NodeF(Left(eoTree), Right(-1)) else BinF.LeafF(1) } @@ -107,18 +107,18 @@ class SchemesBench extends JmhDefaults: } ) - @Benchmark def eoApoGraft: Bin = eoApoGraftG.get(0) + @Benchmark def eoApoGraft: Bin = eoApoGraftR.reverseGet(0) @Benchmark def drosteApoGraft: Fix[BinF] = drosteApoGraftFn(0) - // ----- materializing refold: andThen-spelling vs manual (both build the Bin) -- - // Post directional-flip, `ana.andThen(cata)` IS the materializing hylo (builds the - // Bin, then folds) — the two spellings should allocate identically. The *fused* + // ----- 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 eoRefoldAndThenG = Schemes.ana[BinF, Int, Bin](eoTypedCoalg).andThen(Schemes.cata(eoTypedSum)) + val eoRefoldCrossG = Schemes.ana[BinF, Int, Bin](eoTypedCoalg).cross(Schemes.cata(eoTypedSum)) - @Benchmark def eoRefoldAndThen: Int = eoRefoldAndThenG.get(Depth) - @Benchmark def eoRefoldManual: Int = eoCataG.get(eoAnaG.get(Depth)) + @Benchmark def eoRefoldCross: Int = eoRefoldCrossG.get(Depth) + @Benchmark def eoRefoldManual: Int = eoCataG.get(eoAnaR.reverseGet(Depth)) // ----- generic decoration route (user-written Gather, no identity fast path) -- 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 index cb236dda..14257f5c 100644 --- 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 @@ -38,18 +38,21 @@ apo, histo, futu — built on three structural commitments (v2): for the fused result (and the always-fused spelling), not a third independent driver. - > **Superseded (2026-06-13, directional flip).** The `.cross` framing above was - > a directional inconsistency: pure `ana` shipped as a *backward* `Review[S, Seed]` - > while its effectful twin `anaM` was a *forward* `FoldM[Seed, S]`. `.cross` was - > only needed to undo that backwardness. Resolution: pure `ana`/`apo`/`futu` are now - > **forward `Getter[Seed, S]`** (mirroring `anaM`), so the materialising refold is - > plain `ana.andThen(cata) : Getter[Seed, A]` via the core fused `Getter.andThen` — - > no `cross`, no `Cata`/`Ana`/`CataM`/`AnaM` clone classes (deleted). `Schemes.hylo` - > stays the fused (no-intermediate-`S`) spelling. Note the flip makes `ana.andThen(cata)` - > genuinely *materialising* (885k B/op, == manual `cata.get(ana.get())`); the old - > fused-pairs `.cross` member (820k) is gone, but the real fusion win lives in `hylo` - > (361k, unchanged). The M path mirrors this: `anaM.andThen(cataM)` is a concrete - > `FoldM.andThen` (Kleisli `flatMap`, materialising); `hyloM` is the fused spelling. + > **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 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 060a83bb..3978cee0 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -4,17 +4,18 @@ package schemes import cats.{Monad, Traverse} import data.{Forget, ForgetK} -import optics.{Getter, Optic} +import optics.{Getter, Optic, Review} import zoo.* /** 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` — * algebras pattern-match `F`'s *named constructors*, no positional indexing. * - * - [[cata]] folds (`Getter[S, A]`); [[ana]] unfolds (`Getter[Seed, S]` — forward, mirroring - * [[anaM]]'s `FoldM[Seed, S]`); both are plain `Getter`s, so `ana.andThen(cata) : Getter[Seed, - * A]` is the materialising hylo via the core fused `Getter.andThen`. [[hylo]] is the **fused** - * zero-`S` refold (builds no intermediate `S`); `ana.andThen(cata) == hylo` is the hylo law. + * - [[cata]] folds (`Getter[S, A]`); [[ana]] unfolds (`Review[S, Seed]`) — the build-only mirror + * of `cata`'s read, the Getter↔Review duality. So the materialising hylo is `ana.cross(cata) : + * Getter[Seed, A]` — exactly the build-output⇄read-input seam `Optic.cross` is named for. + * [[hylo]] is the **fused** zero-`S` refold (builds no intermediate `S`); `ana.cross(cata) == + * hylo` is the hylo law. * - The zoo: [[para]] (subterms paired from the walked nodes), [[apo]] (O(1) graft), [[histo]] / * [[futu]] (course-of-value / multi-layer, via [[Attr]] / [[Coattr]]). Decorated generically * through the [[Gather]]/[[Scatter]] decoration optics (over the `BiAffine` carrier) — @@ -130,16 +131,17 @@ object Schemes: ) Getter[S, A](s => toAttr(s).head) - /** Anamorphism over a typed pattern functor `F`, as a `Review`. The single fused coalgebra `Seed - * => F[Seed]` yields one typed layer of child seeds; [[Embed]] assembles each layer into the - * built `S`. Materializing — the built `S` is O(nodes). Stack-safe (the [[Machines.foldLayered]] - * machine). Requires `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input - * before output) to match [[hylo]] and the `PSVec` [[ana]]. + /** Anamorphism over a typed pattern functor `F`, as a `Review` (`reverseGet: Seed => S`) — the + * build-only mirror of [[cata]]'s read. The single fused coalgebra `Seed => F[Seed]` yields one + * typed layer of child seeds; [[Embed]] assembles each layer into the built `S`. Materializing — + * the built `S` is O(nodes). Stack-safe (the [[Machines.foldLayered]] machine). Requires + * `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input before output) to match + * [[hylo]]. Compose onto a fold with `ana.cross(cata)` (the build⇄read seam). */ def ana[F[_], Seed, S]( coalg: Seed => F[Seed] - )(using F: Traverse[F], E: Embed[F, S]): Getter[Seed, S] = - Getter[Seed, S]( + )(using F: Traverse[F], E: Embed[F, S]): Review[S, Seed] = + Review[S, Seed]( Machines.foldLayered[F, Seed, S]( coalg, (_, fSeed, out) => E.embed(Machines.rebuildLayer[F, Seed, S](fSeed, out)), @@ -155,7 +157,7 @@ object Schemes: */ def apo[F[_], A, S]( coalg: A => F[Either[S, A]] - )(using F: Traverse[F], E: Embed[F, S]): Getter[A, S] = + )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = val run = Machines.foldLayeredOr[F, Either[S, A], S]( { case Left(s) => Left(s) @@ -163,7 +165,7 @@ object Schemes: }, (fw, out) => E.embed(Machines.rebuildLayer[F, Either[S, A], S](fw, out)), ) - Getter[A, S](a => run(Right(a))) + Review[S, A](a => run(Right(a))) /** Futumorphism over a typed pattern functor `F` — the coalgebra may emit **multiple layers per * step** ([[Coattr]]: `Pure` keeps unfolding, `Roll` is a prebuilt layer unrolled with no @@ -176,7 +178,7 @@ object Schemes: */ def futu[F[_], A, S]( coalg: A => F[Coattr[F, A]] - )(using F: Traverse[F], E: Embed[F, S]): Getter[A, S] = + )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = val expand: Coattr[F, A] => F[Coattr[F, A]] = case Coattr.Pure(a) => coalg(a) case Coattr.Roll(layer) => layer @@ -184,7 +186,7 @@ object Schemes: expand, (_, fw, out) => E.embed(Machines.rebuildLayer[F, Coattr[F, A], S](fw, out)), ) - Getter[A, S](a => build(Coattr.Pure(a))) + Review[S, A](a => build(Coattr.Pure(a))) /** Generalized (decorated) anamorphism — the gana of the typed path, with the decoration supplied * as a [[Scatter]] optic value. Each `W` slot is scattered ([[Scatter.scatter]], called directly @@ -199,7 +201,7 @@ object Schemes: */ def ana[F[_], A, W, S]( scatter: Scatter[F, W, A] - )(gcoalg: A => F[W])(using F: Traverse[F], E: Embed[F, S]): Getter[A, S] = + )(gcoalg: A => F[W])(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = val expand: W => F[W] = w => scatter.scatter(w) match case Right(a) => gcoalg(a) @@ -208,13 +210,13 @@ object Schemes: expand, (_, fw, out) => E.embed(Machines.rebuildLayer[F, W, S](fw, out)), ) - Getter[A, S](a => build(scatter.unit(a))) + Review[S, A](a => build(scatter.unit(a))) /** Hylomorphism over a typed pattern functor `F` — the **fused** refold `Seed => A`, building * **no intermediate `S`** (so it needs neither `Project` nor `Embed`, only `Traverse[F]`). * `coalg` unfolds a seed into one typed layer; `alg` folds the layer's results to `A` (the seed * is supplied, paramorphism-flavored). Stack-safe (the [[Machines.foldLayered]] machine). Equal - * to the materialising `ana(coalg).andThen(cata(alg))` for a *pure* algebra (the hylo law) — + * to the materialising `ana(coalg).cross(cata(alg))` for a *pure* algebra (the hylo law) — * `hylo` fuses it into one pass with no intermediate `S`; for a node-reading para algebra the * two agree only under the seed↔`embed(coalg(seed))` correspondence. */ @@ -243,9 +245,15 @@ object Schemes: ) /** Effectful anamorphism — the coalgebra (the layer producer) runs in `M` (`Seed => M[F[Seed]]`, - * the arbo `GetSellOptions` shape: fetching children is effectful). Returns the [[zoo.FoldM]] - * citizen; consume via `.run`; `anaM.andThen(cataM)` is the materialising effectful hylo, - * `hyloM` the fused one. + * the arbo `GetSellOptions` shape: fetching children is effectful). + * + * '''M-rung encoding (not a `Review`).''' On the pure rung [[ana]] is a `Review` (a build, the + * dual of [[cata]]'s `Getter`). The M effect, though, only fits the Kleisli *read* slot of + * `Forget[M]` (`to: Seed => M[S]`) — there is no carrier for "build with effect on the output" — + * so `anaM` is a [[zoo.FoldM]] `Seed => M[S]`, a Kleisli arrow that is *semantically* a build. + * The read/build duality of the pure rung collapses into Kleisli arrows here, and composition is + * `anaM.andThen(cataM)` (Kleisli `flatMap`, the materialising effectful hylo) rather than the + * pure rung's `cross`; `hyloM` is the fused one. Consume via `.run`. */ def anaM[M[_], F[_], Seed, S]( coalgM: Seed => M[F[Seed]] 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 index 9530593e..15fc8830 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala @@ -28,10 +28,11 @@ import optics.Optic * own fresh mutable state (the frame deque is allocated inside the `M`, not before it). Concurrent * forcing of a single `M[A]` value remains unsupported; each `run(s)` call is independent. * - * Composition: `anaM.andThen(cataM)` composes two `Forget[M]` citizens at the focus seam (via - * `assocForgetMonad`) into the materialising effectful hylo (`M[S]` built, then folded); - * `Schemes.hyloM` is the fused one-pass spelling (no `M[S]`). This mirrors the pure side exactly, - * where `ana.andThen(cata)` is the materialising hylo and `Schemes.hylo` the fused one. + * Composition: `anaM.andThen(cataM)` Kleisli-chains two `FoldM`s (`M.flatMap`) into the + * materialising effectful hylo (`M[S]` built, then folded); `Schemes.hyloM` is the fused one-pass + * spelling (no `M[S]`). NB the M rung composes via `andThen` (both `cataM` and `anaM` are Kleisli + * arrows `_ => M[_]` — see [[Schemes.anaM]] on why the effect collapses the read/build duality), + * whereas the pure rung composes a `Review` (`ana`) into a `Getter` (`cata`) via `cross`. * * `FoldM` is a concrete, public citizen: `cataM` / `anaM` / `hyloM` all return it, and users may * wrap their own `S => M[A]` as one. diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala index da11b075..854bedec 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala @@ -3,14 +3,16 @@ package schemes import org.specs2.mutable.Specification -import optics.Getter +import optics.Optic.* import schemes.samples.{Bin, BinF} -/** The hylo law as composition: `ana.andThen(cata) == hylo`. +/** The hylo law as composition: `ana.cross(cata) == hylo`. * - * Both `ana` and `cata` are forward `Getter`s (`Getter[Seed, S]` and `Getter[S, A]`), so they - * compose at the focus seam with the core fused `Getter.andThen` — no `cross`, no clone classes. - * - `ana.andThen(cata)` is the **materialising** hylo: it builds the full `S`, then folds it. + * `ana` is a build-only `Review[Bin, Int]` and `cata` a read-only `Getter[Bin, Int]` — duals over + * `Direct`. The build-output⇄read-input seam between them is `Optic.cross` (its scaladoc names + * `ana.cross(cata)` the motivating case), yielding a forward Getter-shaped read consumed via + * `.get`. + * - `ana.cross(cata)` is the **materialising** hylo: it builds the full `S`, then folds it. * - `Schemes.hylo(coalg, alg)` is the **fused** hylo: one pass, no intermediate `S`. * - They agree on every seed (the hylo law) for a pure algebra; for a node-reading para algebra * only under the seed↔`embed(coalg(seed))` correspondence. @@ -34,22 +36,22 @@ class FusionSpec extends Specification: case BinF.LeafF(n) => n case BinF.BranchF(l, r) => l + r - "ana.andThen(cata) composes via the core fused Getter.andThen (no cross)" >> { - val hylo: Getter[Int, Int] = Schemes.ana[BinF, Int, Bin](expand).andThen(Schemes.cata(sumAlg)) + "ana.cross(cata) composes via the build⇄read seam into a forward read" >> { + val hylo = Schemes.ana[BinF, Int, Bin](expand).cross(Schemes.cata(sumAlg)) hylo.get(6) === 6 // six leaves of weight 1 } - "ana.andThen(cata) == cata.get ∘ ana.get (the materialising composition)" >> { + "ana.cross(cata) == cata.get ∘ ana.reverseGet (the materialising composition)" >> { val seeds = List(1, 2, 3, 5, 8, 13) val ana = Schemes.ana[BinF, Int, Bin](expand) val cata = Schemes.cata(sumAlg) - val composed = ana.andThen(cata) - seeds.map(composed.get) === seeds.map(s => cata.get(ana.get(s))) + val composed = ana.cross(cata) + seeds.map(composed.get) === seeds.map(s => cata.get(ana.reverseGet(s))) } - "ana.andThen(cata) == hylo for a pure algebra (the hylo law)" >> { + "ana.cross(cata) == hylo for a pure algebra (the hylo law)" >> { val seeds = List(1, 2, 3, 5, 8, 13) - val composed = Schemes.ana[BinF, Int, Bin](expand).andThen(Schemes.cata(sumAlg)) + val composed = Schemes.ana[BinF, Int, Bin](expand).cross(Schemes.cata(sumAlg)) seeds.map(composed.get) === seeds.map(Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get) } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala index 068fa1e8..c7dee650 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala @@ -127,8 +127,8 @@ class GatherScatterLawsSpec extends Specification: "the generic decoration route agrees with the direct overload on ana" >> { def expand(n: Int): BinF[Int] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) - Schemes.ana[BinF, Int, Int, Bin](Scatter.ana[BinF, Int])(expand).get(5) === - Schemes.ana[BinF, Int, Bin](expand).get(5) + Schemes.ana[BinF, Int, Int, Bin](Scatter.ana[BinF, Int])(expand).reverseGet(5) === + Schemes.ana[BinF, Int, Bin](expand).reverseGet(5) } "histo through Gather.histo: heads-only course-of-value == cata" >> { @@ -148,7 +148,7 @@ class GatherScatterLawsSpec extends Specification: else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) val built = Schemes .ana[BinF, Int, Coattr[BinF, Int], Bin](Scatter.futu[BinF, Int])(coalg) - .get(3) + .reverseGet(3) built === Bin.Branch(Bin.Leaf(3), Bin.Branch(Bin.Leaf(2), Bin.Leaf(1))) } @@ -174,8 +174,8 @@ class GatherScatterLawsSpec extends Specification: def coalg(n: Int): BinF[Coattr[BinF, Int]] = if n <= 1 then BinF.LeafF(1) else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) - Schemes.futu[BinF, Int, Bin](coalg).get(4) === - Schemes.ana[BinF, Int, Coattr[BinF, Int], Bin](Scatter.futu[BinF, Int])(coalg).get(4) + Schemes.futu[BinF, Int, Bin](coalg).reverseGet(4) === + Schemes.ana[BinF, Int, Coattr[BinF, Int], Bin](Scatter.futu[BinF, Int])(coalg).reverseGet(4) } // ----- generic-route end-to-end pins -------------------------------------- @@ -205,9 +205,9 @@ class GatherScatterLawsSpec extends Specification: def coalg(n: Int): BinF[Either[Bin, Int]] = if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Left(Bin.Leaf(n)), Right(n - 1)) - val nativeResult = Schemes.apo[BinF, Int, Bin](coalg).get(2) + val nativeResult = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(2) val genericResult = - Schemes.ana[BinF, Int, Either[Bin, Int], Bin](apoScatter[BinF, Bin, Int])(coalg).get(2) + Schemes.ana[BinF, Int, Either[Bin, Int], Bin](apoScatter[BinF, Bin, Int])(coalg).reverseGet(2) // Both produce the same tree by value; use == not eq (generic route REBUILDS the graft). nativeResult === genericResult } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala index 9bf54a8d..a79ab088 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala @@ -75,11 +75,11 @@ class SchemesConcurrencySpec extends Specification: ) .get(seed) val expectedHylo = - Schemes.cata(sumAlg).get(Schemes.ana[BinF, Int, Bin](expand).get(seed)) + Schemes.cata(sumAlg).get(Schemes.ana[BinF, Int, Bin](expand).reverseGet(seed)) val hyloOk = hyloResult == expectedHylo // (c) ana builds a per-task Bin from a per-task seed, cata folds it back - val built: Bin = Schemes.ana[BinF, Int, Bin](expand).get(seed) + val built: Bin = Schemes.ana[BinF, Int, Bin](expand).reverseGet(seed) val anaOk = Schemes.cata(sumAlg).get(built) == expectedHylo // (d) para on the shared tree ignoring subterms == cata @@ -92,7 +92,7 @@ class SchemesConcurrencySpec extends Specification: val futuCoalg: 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)) - val futuBuilt: Bin = Schemes.futu[BinF, Int, Bin](futuCoalg).get(seed) + val futuBuilt: Bin = Schemes.futu[BinF, Int, Bin](futuCoalg).reverseGet(seed) val futuOk = Schemes.cata(sumAlg).get(futuBuilt) == expectedHylo // (g) cataM[Eval].run forced per task diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala index a04adc13..05389609 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala @@ -14,7 +14,7 @@ import schemes.samples.{Bin, BinF, Rose, RoseF} * * - '''Project/Embed coherence''' — the hand-written `S`↔`F` correspondence is not * compiler-checked, so its two round-trip laws are property-tested here. - * - '''Hylo law (pure flavor)''' — `hylo == ana.andThen(cata)` holds *generically* only when the + * - '''Hylo law (pure flavor)''' — `hylo == ana.cross(cata)` holds *generically* only when the * algebra ignores its node argument (a pure `F[A] => A` fold). Tested via `forAll`. * - '''Hylo law (para flavor)''' — for a node-reading algebra, `hylo` threads the *seed* while * the materializing `cata` threads the rebuilt `S`, so the two coincide only under the @@ -95,13 +95,13 @@ class SchemesLawsSpec extends Specification with ScalaCheck: case BinF.BranchF(l, r) => l + r } - "hylo == ana.andThen(cata) for a PURE algebra (the hylo law)" >> { + "hylo == ana.cross(cata) for a PURE algebra (the hylo law)" >> { forAll(Gen.choose(0, 12)) { (seed: Int) => val fused = Schemes.hylo[BinF, Int, Int](coalg, (_, fa) => pureSum(fa)).get(seed) val materializing = Schemes .ana[BinF, Int, Bin](coalg) - .andThen(Schemes.cata[BinF, Bin, Int]((_, fa) => pureSum(fa))) + .cross(Schemes.cata[BinF, Bin, Int]((_, fa) => pureSum(fa))) .get(seed) fused == materializing } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala index 64869241..11438916 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala @@ -49,9 +49,9 @@ class SchemesMSpec extends Specification: Schemes.cata(sumAlg).get(tree) } - "anaM[Id].run == ana.get" >> { + "anaM[Id].run == ana.reverseGet" >> { Schemes.anaM[Id, BinF, Int, Bin](n => expand(n)).run(6) === - Schemes.ana[BinF, Int, Bin](expand).get(6) + Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) } "hyloM[Id].run == hylo.get" >> { 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 101184d6..f3a79c8f 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -9,7 +9,7 @@ import org.specs2.mutable.Specification import data.Forget import data.Forget.given import optics.{Getter, Lens, Optic} -import optics.Optic.* // get, andThen, foldMap +import optics.Optic.* // get, andThen, cross, reverseGet, foldMap import schemes.samples.{Bin, BinF, Rose, RoseF} @@ -90,7 +90,7 @@ class SchemesSpec extends Specification: "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).get(3) + val built: Bin = Schemes.ana[BinF, Int, Bin](spine).reverseGet(3) // seed 3 -> Branch(Branch(Branch(Leaf 1, Leaf 1), Leaf 1), Leaf 1): 4 leaves of 1, depth 3 (Schemes.cata(sumLeaves).get(built) == 4).and( Schemes @@ -103,9 +103,9 @@ class SchemesSpec extends Specification: ) } - "ana.andThen(cata) is the materializing hylo (builds the Bin, then folds)" >> { + "ana.cross(cata) is the materializing hylo (builds the Bin, then folds)" >> { val spine: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(n - 1, -1) - val refold = Schemes.ana[BinF, Int, Bin](spine).andThen(Schemes.cata(sumLeaves)) + val refold = Schemes.ana[BinF, Int, Bin](spine).cross(Schemes.cata(sumLeaves)) (refold.get(3) == 4) must beTrue } @@ -201,7 +201,7 @@ class SchemesSpec extends Specification: "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).get(Deep) + val deep: Bin = Schemes.ana[BinF, Int, Bin](coalg).reverseGet(Deep) val depth: (Bin, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 0 @@ -233,7 +233,7 @@ class SchemesSpec extends Specification: if d <= 0 then RoseF(0, Nil) else RoseF(d, (d - 1) :: List.fill(Width)(-1)) val countNodes: (Rose, RoseF[Int]) => Int = (_, fr) => 1 + fr.kids.sum - val built: Rose = Schemes.ana[RoseF, Int, Rose](coalg).get(DeepRose) + val built: Rose = Schemes.ana[RoseF, Int, Rose](coalg).reverseGet(DeepRose) val expected = (DeepRose + 1) + DeepRose * Width (Schemes.cata(countNodes).get(built) == expected) must beTrue } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala index 974b8e7d..e91edec4 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala @@ -62,8 +62,8 @@ class SchemesZooSpec extends Specification: if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) val viaApo = Schemes .apo[BinF, Int, Bin](n => BinF.traverse.map(expand(n))(Right(_))) - .get(6) - viaApo === Schemes.ana[BinF, Int, Bin](expand).get(6) + .reverseGet(6) + viaApo === Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) } "heads-only histo == cata" >> { @@ -80,8 +80,8 @@ class SchemesZooSpec extends Specification: 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(_))) - .get(6) - viaFutu === Schemes.ana[BinF, Int, Bin](expand).get(6) + .reverseGet(6) + viaFutu === Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) } // ----- the graft law (O(1), by reference) ---------------------------------- @@ -93,7 +93,7 @@ class SchemesZooSpec extends Specification: if n <= 0 then BinF.LeafF(7) else if n == 1 then BinF.BranchF(Left(grafted), Right(0)) else BinF.BranchF(Right(n - 1), Right(n - 1)) - val built = Schemes.apo[BinF, Int, Bin](coalg).get(1) + val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(1) val graftSlot = built match case Bin.Branch(g, _) => g case other => other @@ -108,7 +108,7 @@ class SchemesZooSpec extends Specification: if n <= 0 then BinF.LeafF(0) else if n == 1 then BinF.BranchF(Left(grafted), Right(0)) else BinF.BranchF(Right(n - 1), Left(Bin.Leaf(0))) - val built = Schemes.apo[BinF, Int, Bin](coalg).get(GraftDepth) + val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(GraftDepth) // Navigate left spine to find the graft slot (iterative — safe at any depth) var cursor: Bin = built var steps = GraftDepth - 1 @@ -165,7 +165,7 @@ class SchemesZooSpec extends Specification: "apo is stack/space-safe building a 10^6-deep Bin" >> { def coalg(n: Int): BinF[Either[Bin, Int]] = if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Right(n - 1), Left(Bin.Leaf(0))) - val built = Schemes.apo[BinF, Int, Bin](coalg).get(Deep) + val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(Deep) val depth: (Bin, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 0 @@ -187,7 +187,7 @@ class SchemesZooSpec extends Specification: if n <= 0 then BinF.LeafF(0) else if n % 2 == 0 then BinF.BranchF(Coattr.Roll(BinF.LeafF(0)), Coattr.Pure(n - 1)) else BinF.BranchF(Coattr.Pure(n - 1), Coattr.Roll(BinF.LeafF(0))) - val built = Schemes.futu[BinF, Int, Bin](coalg).get(Deep) + val built = Schemes.futu[BinF, Int, Bin](coalg).reverseGet(Deep) val size: (Bin, BinF[Int]) => Int = (_, fa) => fa match case BinF.LeafF(_) => 1 diff --git a/site/docs/schemes.md b/site/docs/schemes.md index 6cbf2ead..3abbbdcf 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -8,7 +8,7 @@ constructors** (compile-time arity safety, no positional indexing): | Scheme | Optic | Direction | |--------|-------|-----------| | `cata` | `Getter[S, A]` | fold an existing `S` to an `A` | -| `ana` | `Getter[Seed, S]` | build an `S` from a seed | +| `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` | `Forget[M]`-carried | effectful steps in a `Monad[M]` | @@ -103,14 +103,15 @@ val countLeavesF: Getter[Int, Int] = ``` ```scala mdoc -sumLeavesF.get(buildBin.get(3)) // 4 unit leaves +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 ``` -`cata`, `ana`, and `hylo` are all **forward `Getter`s** (`ana` builds, reading `Seed => S`), so -they compose with the rest of the optic algebra via `andThen` (the materializing -`ana(…).andThen(cata(…))` equals the fused `hylo` for a pure algebra — the hylo law). They run on +`cata` and `hylo` are **`Getter`s** (forward reads) and `ana` is a **`Review`** (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 @@ -199,7 +200,7 @@ val patched = Schemes.apo[BinF, Int, Bin] { n => ``` ```scala mdoc -patched.get(2) +patched.reverseGet(2) ``` `histo` gives the algebra each child's **entire decorated history** (`Attr[F, A]`: the result @@ -231,13 +232,15 @@ val twoAtATime = Schemes.futu[BinF, Int, Bin] { n => } ``` -### Composition and fusion: `andThen` vs `hylo` +### Composition and fusion: `cross` vs `hylo` -Because `ana` and `cata` are both forward `Getter`s, the unfold-then-fold refold is just their -`andThen` at the focus seam — no `cross`, no clone classes. `ana(…).andThen(cata(…))` is the -**materialising** 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). +`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 zooExpand: Int => BinF[Int] = n => @@ -245,7 +248,7 @@ val zooExpand: Int => BinF[Int] = n => val zooSum: (Bin, BinF[Int]) => Int = (_, fa) => fa match { case BinF.LeafF(n) => n; case BinF.BranchF(l, r) => l + r } -val fusedLeafSum = Schemes.ana[BinF, Int, Bin](zooExpand).andThen(Schemes.cata(zooSum)) +val fusedLeafSum = Schemes.ana[BinF, Int, Bin](zooExpand).cross(Schemes.cata(zooSum)) ``` ```scala mdoc @@ -290,7 +293,9 @@ When producing a layer is itself effectful — fetching a node's children from a supported Ms are single-pass and *linear* — a branching/replaying `M` like `List` is documented unsupported). Results are `Forget[M]`-carried `FoldM` citizens consumed via `.run`; `anaM.andThen(cataM)` is the materialising effectful hylo (Kleisli `flatMap` — `M[S]` built, then -folded), mirroring the pure side, with `Schemes.hyloM` the fused one-pass spelling: +folded), with `Schemes.hyloM` the fused one-pass spelling. (On the M rung the effect only fits the +Kleisli read slot, so both `cataM` and `anaM` are `FoldM`s composed by `andThen` — the pure rung's +`Review`/`Getter` `cross` duality collapses into Kleisli arrows here.) ```scala mdoc:silent import cats.data.State @@ -316,7 +321,7 @@ The decoration optics' carrier is new in core: **`BiAffine`** — `Affine`'s dat seam. `Step(context, focus)` keeps going; `Done(payload)` means "this slot is already finished — do not call the coalgebra" (apo grafts a finished subtree, futu unrolls a prebuilt layer). Its laws are the graft-finality and round-trip equations in `cats-eo-laws`. Composition here is -scoped to the shipped seams — the `Getter.andThen` refold (pure) and `FoldM.andThen` (M-path), +scoped to the shipped seams — the `ana.cross(cata)` refold (pure) and `FoldM.andThen` (M-path), plus the fused `hylo`/`hyloM` drivers; BiAffine's full composition-matrix row is follow-up work, as are the elgot/coelgot decorations (the answer-level short-circuit, which the M machine's internals are already shaped for). From 46c6d0fd4f61887bacf607eea10d7acbfa97632b Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sat, 13 Jun 2026 13:40:50 +0200 Subject: [PATCH 32/61] =?UTF-8?q?spike(schemes):=20X-indexed=20fusion=20pr?= =?UTF-8?q?ototype=20=E2=80=94=20ana.cross(cata)=20at=20hylo=20cost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates "the honest hylo optic": scheme reads/builds on a dedicated `Scheme` carrier (value-identical to Direct but a distinct opaque type — honest citizens, no Getter/Review clones, no asGetter) that carry their (co)algebra, so `cross` can rebuild the one-pass hylo machine instead of materialising the tree. The existential X records the fold's STRUCTURAL DEPENDENCY, which is exactly the soundness condition for deforestation: - SchemeCata, X = Nothing (node-blind F[A] => A) → cross FUSES - SchemePara, X = S (node-reading (S,F[A]) => A) → cross MATERIALISES Same `cross` spelling; the overload is selected by the argument's X. Empirical (-prof gc, ProtoFusionBench): protoFusedCross 361,386 B/op == eoHyloRef 361,385 (fused, no Bin built) protoParaCross 885,577 B/op (materialises — para needs the tree) protoManual 885,579 B/op (cata.get(ana.reverseGet)) Correctness (ProtoFusionSpec, 2/2): fused == materialising (hylo law); the fused cross is stack-safe at depth 10^6. Spike only — concrete cross overload (not generic; matches the prior inline-generic-andThen-fusion finding), not yet wired into core's compose matrix or the M rung. See docs/brainstorms/2026-06-12-existential-x-is-the-decoration.md. Co-Authored-By: Claude Fable 5 --- .../eo/bench/ProtoFusionBench.scala | 55 +++++++ .../eo/schemes/proto/SchemeCarrier.scala | 144 ++++++++++++++++++ .../eo/schemes/proto/ProtoFusionSpec.scala | 58 +++++++ 3 files changed, 257 insertions(+) create mode 100644 benchmarks/src/main/scala/dev/constructive/eo/bench/ProtoFusionBench.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/proto/ProtoFusionSpec.scala diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/ProtoFusionBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/ProtoFusionBench.scala new file mode 100644 index 00000000..3587ec65 --- /dev/null +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/ProtoFusionBench.scala @@ -0,0 +1,55 @@ +package dev.constructive.eo +package bench + +import org.openjdk.jmh.annotations.* +import java.util.concurrent.TimeUnit + +import dev.constructive.eo.bench.fixture.* +import dev.constructive.eo.bench.fixture.SchemesFixtures.given +import dev.constructive.eo.optics.Optic.* +import dev.constructive.eo.schemes.Schemes +import dev.constructive.eo.schemes.proto.{Proto, Scheme} +import Scheme.given + +/** Prototype validation: does `ana.cross(cata)` on the `Scheme` carrier fuse to `hylo` cost? + * + * - `protoFusedCross` — `ana.cross(cata)` with a node-BLIND `cata` (X = Nothing): should rebuild + * the single-pass hylo machine — NO intermediate `Bin` — so ≈ `eoHyloRef` (≈361k B/op). + * - `protoParaCross` — `ana.cross(para)` with a node-READING `para` (X = S): the overload picks + * the materialising branch (build the `Bin`, then fold) — ≈885k B/op. + * - `protoManual` — `cata.get(ana.reverseGet(...))`, the hand-written materialisation — ≈885k. + * + * The X-resolution (Nothing vs S) is what selects fused vs materialising — same `cross` spelling. + */ +@State(Scope.Benchmark) +@BenchmarkMode(Array(Mode.AverageTime)) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(3) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +class ProtoFusionBench extends JmhDefaults: + import SchemesFixtures.* + + final val Depth = 12 // 2^12 = 4096 leaves, 8191 nodes — same workload as SchemesBench + + // node-BLIND fold (true catamorphism) — fusable + private val pureSum: BinF[Int] => Int = { + case BinF.LeafF(v) => v + case BinF.NodeF(l, r) => l + r + } + + // Prebuilt optics (construction not measured). + val protoCata = Proto.cata[BinF, Bin, Int](pureSum) + val protoPara = Proto.para[BinF, Bin, Int](eoTypedSum) // node-READING (X = S) + val protoAna = Proto.ana[BinF, Int, Bin](eoTypedCoalg) + + val protoFusedG = protoAna.cross(protoCata) // X_cata = Nothing → fused + val protoParaG = protoAna.cross(protoPara) // X_para = S → materialising + + // Reference: the existing fused hylo (node-blind), the bar to hit. + val eoHyloRefG = Schemes.hylo[BinF, Int, Int](eoTypedCoalg, (_, fa) => pureSum(fa)) + + @Benchmark def protoFusedCross: Int = protoFusedG.get(Depth) + @Benchmark def protoParaCross: Int = protoParaG.get(Depth) + @Benchmark def protoManual: Int = protoCata.get(protoAna.reverseGet(Depth)) + @Benchmark def eoHyloRef: Int = eoHyloRefG.get(Depth) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala new file mode 100644 index 00000000..93174c4a --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala @@ -0,0 +1,144 @@ +package dev.constructive.eo +package schemes +package proto + +import cats.Traverse + +import accessor.{Accessor, ReverseAccessor} +import optics.Optic + +// =========================================================================================== +// PROTOTYPE — "the honest hylo optic": schemes as X-indexed optics that fuse at `cross`. +// +// The motivating question (PR #24 follow-up): can `ana.cross(cata)` be as cheap as `hylo` +// (no intermediate `S`) instead of materialising the whole tree? The plain `Direct` +// `Getter`/`Review` CANNOT — they have collapsed to opaque `Seed => S` / `S => A` closures, +// throwing away the `coalg`/`alg`, so the `project ∘ embed = id` cancellation that fusion +// rides on is invisible. Fusion needs the (co)algebra carried. +// +// This prototype carries it on a dedicated carrier `Scheme` (value-level identical to +// `Direct`, but a DISTINCT opaque type so the scheme optics are honest citizens — not +// `Getter`/`Review` clones — and so the fused `cross` overload is reachable). The existential +// `X` records the fold's STRUCTURAL DEPENDENCY, which is exactly the soundness condition for +// deforestation: +// +// cata : X = Nothing node-BLIND fold (F[A] => A) → cross FUSES (no S, hylo machine) +// para : X = S node-READING fold ((S, F[A]) => A) → cross MATERIALISES (needs the S) +// +// "ana.cross(cata) fused vs materialising are the same optic at two X-resolutions" +// (docs/brainstorms/2026-06-12-existential-x-is-the-decoration.md) made operational and SOUND: +// the resolution is forced by whether the algebra reads its node. The fused runtime is just +// `Machines.foldLayered(coalg, alg)` — i.e. `hylo` — so the win is already proven; the novelty +// here is purely the type encoding that lets `cross` pick it. +// =========================================================================================== + +/** The scheme carrier: `Scheme[X, A] = A` (the focus; `X` is a phantom at the value level, a + * type-level tag at the optic level). Distinct from `Direct` on purpose — see the banner. + */ +opaque type Scheme[X, A] = A + +object Scheme: + inline def apply[X, A](a: A): Scheme[X, A] = a + extension [X, A](s: Scheme[X, A]) inline def value: A = s + + given Accessor[Scheme] with + def get[X, A](fa: Scheme[X, A]): A = fa + + given ReverseAccessor[Scheme] with + def reverseGet[X, A](a: A): Scheme[X, A] = a + +/** Pure catamorphism — a node-BLIND fold `alg: F[A] => A`. `X = Nothing`: it retains nothing of + * the structure, so `ana.cross(this)` is sound to fuse. Read-only (`.get` via `Accessor[Scheme]`, + * no `asGetter`). + */ +final class SchemeCata[F[_], S, A](private[proto] val alg: F[A] => A)(using + private[proto] val F: Traverse[F], + private[proto] val P: Project[F, S], +) extends Optic[S, Unit, A, Unit, Scheme]: + type X = Nothing + + private val run: S => A = + Machines.foldLayered[F, S, A]( + P.project, + (_, fs, out) => alg(Machines.rebuildLayer[F, S, A](fs, out)), + ) + + def to(s: S): Scheme[X, A] = Scheme(run(s)) + def from(b: Scheme[X, Unit]): Unit = () + +/** Paramorphism — a node-READING fold `alg: (S, F[A]) => A`. `X = S`: it can read the original + * subterm, so it genuinely needs the materialised tree — `ana.cross(this)` MUST build the `S`. + */ +final class SchemePara[F[_], S, A](private[proto] val alg: (S, F[A]) => A)(using + private[proto] val F: Traverse[F], + private[proto] val P: Project[F, S], +) extends Optic[S, Unit, A, Unit, Scheme]: + type X = S + + private[proto] val run: S => A = + Machines.foldLayered[F, S, A]( + P.project, + (s, fs, out) => alg(s, Machines.rebuildLayer[F, S, A](fs, out)), + ) + + def to(s: S): Scheme[X, A] = Scheme(run(s)) + def from(b: Scheme[X, Unit]): Unit = () + +/** The composite `ana.cross(_)` result — a forward read `Seed => A`. Whether `run` is the fused + * one-pass machine or a materialise-then-fold depends on which `cross` overload built it. + */ +final class SchemeGetter[Seed, A](private[proto] val run: Seed => A) + extends Optic[Seed, Unit, A, Unit, Scheme]: + type X = Nothing + def to(s: Seed): Scheme[X, A] = Scheme(run(s)) + def from(b: Scheme[X, Unit]): Unit = () + +/** Anamorphism — build `reverseGet: Seed => S`. `X = S` (the structure it threads). Build-only + * (`.reverseGet` via `ReverseAccessor[Scheme]`). Carries `coalg` so `cross` can fuse. + */ +final class SchemeAna[F[_], Seed, S](private[proto] val coalg: Seed => F[Seed])(using + private[proto] val F: Traverse[F], + private[proto] val E: Embed[F, S], +) extends Optic[Unit, S, Unit, Seed, Scheme]: + type X = S + + private[proto] val build: Seed => S = + Machines.foldLayered[F, Seed, S]( + coalg, + (_, fSeed, out) => E.embed(Machines.rebuildLayer[F, Seed, S](fSeed, out)), + ) + + def to(u: Unit): Scheme[X, Unit] = Scheme(()) + def from(b: Scheme[X, Seed]): S = build(Scheme.value(b)) + + /** FUSED seam — `cata` is node-blind (`X = Nothing`), so deforestation is sound: rebuild the + * single-pass hylo machine from this `coalg` + `cata.alg`. No intermediate `S`. + */ + def cross[A](cata: SchemeCata[F, S, A]): SchemeGetter[Seed, A] = + new SchemeGetter[Seed, A]( + Machines.foldLayered[F, Seed, A]( + coalg, + (_, fSeed, out) => cata.alg(Machines.rebuildLayer[F, Seed, A](fSeed, out)), + )(using F) + ) + + /** MATERIALISING seam — `para` reads its node (`X = S`), so the tree is genuinely needed: build + * the `S`, then fold it. Same `cross` spelling; the overload (driven by the argument's `X`) + * picks this branch. + */ + def cross[A](para: SchemePara[F, S, A]): SchemeGetter[Seed, A] = + new SchemeGetter[Seed, A](seed => para.run(build(seed))) + +/** Prototype constructors mirroring `Schemes.{cata, para, ana}` but on the `Scheme` carrier. */ +object Proto: + def cata[F[_], S, A](alg: F[A] => A)(using Traverse[F], Project[F, S]): SchemeCata[F, S, A] = + new SchemeCata[F, S, A](alg) + + def para[F[_], S, A](alg: (S, F[A]) => A)(using Traverse[F], Project[F, S]): SchemePara[F, S, A] = + new SchemePara[F, S, A](alg) + + def ana[F[_], Seed, S](coalg: Seed => F[Seed])(using + Traverse[F], + Embed[F, S], + ): SchemeAna[F, Seed, S] = + new SchemeAna[F, Seed, S](coalg) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/proto/ProtoFusionSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/proto/ProtoFusionSpec.scala new file mode 100644 index 00000000..eace6399 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/proto/ProtoFusionSpec.scala @@ -0,0 +1,58 @@ +package dev.constructive.eo +package schemes +package proto + +import org.specs2.mutable.Specification + +import optics.Optic.* +import schemes.samples.{Bin, BinF} +import Scheme.given + +/** Correctness pin for the fusion prototype: the fused `ana.cross(cata)` must agree with the + * materialising spellings (the hylo law for a node-blind algebra), and stay stack-safe. + */ +class ProtoFusionSpec extends Specification: + + sequential + + private val coalg: Int => BinF[Int] = n => + if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + + // node-BLIND fold (fusable) + private val pureSum: BinF[Int] => Int = { + case BinF.LeafF(v) => v + case BinF.BranchF(l, r) => l + r + } + + // node-READING fold (para; equals pureSum here but typed to need the node) + private val nodeSum: (Bin, BinF[Int]) => Int = (_, fa) => + fa match + case BinF.LeafF(v) => v + case BinF.BranchF(l, r) => l + r + + private val cata = Proto.cata[BinF, Bin, Int](pureSum) + private val para = Proto.para[BinF, Bin, Int](nodeSum) + private val ana = Proto.ana[BinF, Int, Bin](coalg) + + "fused ana.cross(cata) == manual cata.get(ana.reverseGet) == para.cross materialised" >> { + val seeds = List(1, 2, 3, 5, 8, 13, 21) + val fused = ana.cross(cata) // X = Nothing → fused + val viaPara = ana.cross(para) // X = S → materialising + val fusedR = seeds.map(fused.get) + val manualR = seeds.map(s => cata.get(ana.reverseGet(s))) + val paraR = seeds.map(viaPara.get) + (fusedR === manualR).and(fusedR === paraR) + } + + "the fused cross is stack-safe at depth 10^6 (no intermediate Bin to overflow)" >> { + val Deep = 1_000_000 + def spine(n: Int): BinF[Int] = + if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) + def leafOrSpine(n: Int): BinF[Int] = if n < 0 then BinF.LeafF(0) else spine(n) + val depthAlg: BinF[Int] => Int = { + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) + } + val fused = Proto.ana[BinF, Int, Bin](leafOrSpine).cross(Proto.cata[BinF, Bin, Int](depthAlg)) + (fused.get(Deep) == Deep) must beTrue + } From 0f4d5691e2f20af5a54605013aa7487e42197c8c Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sat, 13 Jun 2026 13:51:33 +0200 Subject: [PATCH 33/61] docs(schemes): clean up stale names from the rename/flip experiments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Propagates the renames that earlier commits left dangling in docs/comments: - F-suffix: `eoHyloF` → `eoHylo`, "cataF/anaF benches" → "cata/ana" - Decor → Gather/Scatter (BiAffine scaladoc, BiAffineLaws, BiAffineSpec, benchmarks.md decoration-route note) - benchmarks.md cross rows: `eoCrossFused`/`eoCrossMaterialized` → `eoRefoldCross`/`eoRefoldManual`, and corrected the now-FALSE "fused cross beats materialising" claim — post duality-fix `ana.cross(cata)` materialises (885 577 B/op, == manual), `hylo` is the fusion (361 385); the X-indexed `proto` spike is what recovers hylo cost through `cross`. Comment/doc only; tests + mdoc green. Co-Authored-By: Claude Fable 5 --- .../eo/bench/fixture/SchemesFixtures.scala | 2 +- .../dev/constructive/eo/data/BiAffine.scala | 8 +++---- .../eo/laws/data/BiAffineLaws.scala | 2 +- .../eo/schemes/proto/SchemeCarrier.scala | 7 +++--- site/docs/benchmarks.md | 23 +++++++++++-------- .../dev/constructive/eo/BiAffineSpec.scala | 4 ++-- 6 files changed, 25 insertions(+), 21 deletions(-) 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 50fca1e2..c194648a 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 @@ -44,7 +44,7 @@ object SchemesFixtures: 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 `cataF`/`anaF` benches. + * pattern functor, for the typed `cata`/`ana` benches. */ given binBasis: Basis[BinF, Bin] = Basis( { diff --git a/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala index 40831290..0a65a021 100644 --- a/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala +++ b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala @@ -6,10 +6,10 @@ import cats.{Applicative, Monoid} import accessor.{Graft, PartialAccessor} import forgetful.* -/** Carrier for the decoration (`Decor`) family of the recursion-scheme zoo — [[Affine]]'s data - * shape worn on the *build* seam. Where `Affine.Miss` means "the read found no focus", - * [[BiAffine.Done]] means "**this slot is already finished** — the engine must not call the - * coalgebra for it". Its payload's meaning is pinned per optic value via the existential `A` +/** Carrier for the decoration (`Gather`/`Scatter`) family of the recursion-scheme zoo — + * [[Affine]]'s data shape worn on the *build* seam. Where `Affine.Miss` means "the read found no + * focus", [[BiAffine.Done]] means "**this slot is already finished** — the engine must not call + * the coalgebra for it". Its payload's meaning is pinned per optic value via the existential `A` * (`Fst[A]`): an apomorphism's `Done` carries a finished subtree (prefill the slot, O(1) graft); a * futumorphism's `Done` carries a prebuilt layer (unroll it, still no coalgebra call). * [[BiAffine.Step]] is the keep-going arm: focus `b` alongside a one-F-layer leftover context diff --git a/laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala index 8c8dcf92..4af0b2b4 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala @@ -15,7 +15,7 @@ import dev.constructive.eo.forgetful.{ForgetfulFold, ForgetfulFunctor, Forgetful * - Graft-channel laws: the `Done` arm is *final* — invisible to the focus (`getOption` empty, * `foldMap` empty) and inert under `map` — while `Step` carries the focus. These are the * carrier-shaped halves of D1's "Done is final"; the per-value `graft(Done(t)) == t` equation - * is stated against concrete `Decor` citizens (which pin `Fst[X]`), not here. + * is stated against concrete `Gather`/`Scatter` citizens (which pin `Fst[X]`), not here. * * The `AssociativeFunctor[BiAffine]` coherence laws are deliberately absent — the carrier ships * without its composition-matrix row (follow-up PR), so there is no `andThen` for them to govern. diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala index 93174c4a..af2730a4 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala @@ -47,9 +47,9 @@ object Scheme: given ReverseAccessor[Scheme] with def reverseGet[X, A](a: A): Scheme[X, A] = a -/** Pure catamorphism — a node-BLIND fold `alg: F[A] => A`. `X = Nothing`: it retains nothing of - * the structure, so `ana.cross(this)` is sound to fuse. Read-only (`.get` via `Accessor[Scheme]`, - * no `asGetter`). +/** Pure catamorphism — a node-BLIND fold `alg: F[A] => A`. `X = Nothing`: it retains nothing of the + * structure, so `ana.cross(this)` is sound to fuse. Read-only (`.get` via `Accessor[Scheme]`, no + * `asGetter`). */ final class SchemeCata[F[_], S, A](private[proto] val alg: F[A] => A)(using private[proto] val F: Traverse[F], @@ -131,6 +131,7 @@ final class SchemeAna[F[_], Seed, S](private[proto] val coalg: Seed => F[Seed])( /** Prototype constructors mirroring `Schemes.{cata, para, ana}` but on the `Scheme` carrier. */ object Proto: + def cata[F[_], S, A](alg: F[A] => A)(using Traverse[F], Project[F, S]): SchemeCata[F, S, A] = new SchemeCata[F, S, A](alg) diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index 8048e489..6eae2547 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -454,7 +454,7 @@ runner): | `drosteHylo` | 328 641 | 1× | | `drosteAna` | 327 632 | 1× | | `eoCata` | 361 385 | 2.2× | -| `eoHyloF` | 361 385 | 1.1× | +| `eoHylo` | 361 385 | 1.1× | | `eoAna` | 524 193 | 1.6× | The residual constant vs droste is the stack-safety machinery (per-node child array + @@ -467,7 +467,7 @@ numbers follow. 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 generic -decoration route, the monadic machine at `cats.Id`, and fused-vs-materialised `cross`. +decoration route, the monadic machine at `cats.Id`, and the materialising `cross` vs fused `hylo`. As above, B/op is the trustworthy column; ns/op is directional. | Method | ns/op | B/op | B/op vs droste | @@ -485,10 +485,10 @@ As above, B/op is the trustworthy column; ns/op is directional. | `eoCata` | 172 428 | 361 385 | 2.19× | | `eoCataGenericRoute` | 162 279 | 362 313 | 2.20× | | `drosteCata` | 56 542 | 164 824 | 1× | -| `eoHyloF` | 180 767 | 361 385 | — | +| `eoHylo` | 180 767 | 361 385 | — | | `eoHyloM` | 303 295 | 820 298 | — | -| `eoCrossFused` | 239 393 | 820 066 | — | -| `eoCrossMaterialized` | 375 824 | 885 579 | — | +| `eoRefoldCross` | — | 885 577 | — | +| `eoRefoldManual` | 375 824 | 885 579 | — | Six results: @@ -505,7 +505,7 @@ Six results: `distApo`-style decoration routes, not to droste's native `zoo.apo`. - **The generic decoration route costs nothing.** A user-written identity gather — which skips the driver's identity fast path — lands at 362 313 B/op vs the fast path's 361 385: escape - analysis elides the per-node decoration wrapper, so writing your own `Decor` route is + analysis elides the per-node decoration wrapper, so writing your own `Gather`/`Scatter` route is alloc-free over `cata`. - **`histo` / `futu` trail droste by ~1.2–1.4× 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 @@ -517,10 +517,13 @@ Six results: 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%**. -- **Fused `cross` beats materialising on both axes.** Composing `ana` into `cata` via - `cross` fuses into one pass — 239 393 ns / 820 066 B/op vs 375 824 ns / 885 579 B/op for - build-the-tree-then-fold (~1.6× faster, no intermediate tree). The fused path also dropped - **−22%** from the previous run's 1 049 417 B/op with the same optimisation commit. +- **`ana.cross(cata)` materialises; `hylo` is the fusion.** `ana` is a build-only `Review` and + `cata` a read-only `Getter` (duals over `Direct`); their `cross` is the build⇄read seam, which + builds the whole `Bin` then folds it — `eoRefoldCross` (885 577 B/op) is byte-identical to the + hand-written `eoRefoldManual` `cata.get(ana.reverseGet(…))` (885 579). The fused, no-intermediate + spelling is `hylo` (361 385 B/op, ~2.4× less). Recovering hylo cost *through* `cross` needs the + optic to carry its (co)algebra — see the `proto` spike (X-indexed `Scheme` carrier), where a + node-blind `cata` makes `ana.cross(cata)` fuse back to 361 386 B/op. ## Reproducing diff --git a/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala index e7fcba98..0260d997 100644 --- a/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala @@ -7,8 +7,8 @@ import data.BiAffine.{Done, Step} import optics.Optic /** Behaviour checks for the [[BiAffine]] carrier worn by an optic — the graft-finality equations a - * full `Decor` citizen must satisfy, stated against a toy citizen here (the named `Decor` values - * in `cats-eo-schemes` state them per value). + * full `Gather`/`Scatter` citizen must satisfy, stated against a toy citizen here (the named + * `Gather`/`Scatter` values 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 `Done` payload is a finished result) and `Snd[X] = F[W]` (the one-F-layer From 383d04e5054f4cb720045dd4c7ddc7a51143d879 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 11:30:24 +0200 Subject: [PATCH 34/61] refactor(schemes)!: recast schemes as existential-indexed optics; hylo/chrono as fusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset the schemes module around the thesis: a recursion scheme is an Optic whose existential X is the index of the recursion, and the (co)free (co)monads are the universal indices. - New `Scheme[X, A]` carrier (opaque, = A): keeps the (co)algebra alive so the fused seams are reachable (Direct's collapsed closures cannot fuse). - Citizens in `schemes.zoo`: Cata (X = Nothing, node-blind F[A] => A), Ana (X = S), Hylo, Histo (X = Attr = cofree), Futu (X = Coattr = free). - hylo is no longer a primitive: `ana.cross(cata)` fuses into the zero-S refold over the Scheme carrier. chrono = `futu.cross(histo)` is hylo at the universal indices (free -> cofree), also fusing; `Schemes.{hylo,chrono}` need only Traverse[F] (no Project/Embed) — the compile-time deforestation proof. - cata is now genuinely node-blind (was paramorphism-flavored `(S, F[A]) => A`). - Machine fix (type safety, not less): rebuildLayer now runs inside the engine, so the raw union `Array[Slot[N,R]]` never escapes into concretely-instantiated code. Algebras receive a typed F[R]. Fixes a latent ClassCastException when both slot halves are Serializable (Coattr/Attr) — the union erased to Serializable[] and failed the Object[] checkcast. - cross is the sole fusion seam (no andThen alias — keeps andThen's compose-at-focus meaning unambiguous). FusionSpec/ChronoSpec witness deforestation via an instrumented Basis (project/embed never called on the fused path). 29/29 green. Stripped the prior decorated/effectful surface (para/apo/gather/scatter/ cataM/anaM/hyloM) and its specs; they return as follow-ups off this spine. Out of scope (still on the old API): benchmarks/*Schemes*, site/docs/schemes.md. Co-Authored-By: Claude Opus 4.8 --- .../constructive/eo/schemes/Machines.scala | 33 +- .../dev/constructive/eo/schemes/Scheme.scala | 48 +++ .../dev/constructive/eo/schemes/Schemes.scala | 331 +++++------------- .../eo/schemes/proto/SchemeCarrier.scala | 145 -------- .../dev/constructive/eo/schemes/zoo/Ana.scala | 36 ++ .../constructive/eo/schemes/zoo/Cata.scala | 29 ++ .../constructive/eo/schemes/zoo/FoldM.scala | 54 --- .../constructive/eo/schemes/zoo/Futu.scala | 47 +++ .../constructive/eo/schemes/zoo/Gather.scala | 83 ----- .../constructive/eo/schemes/zoo/Histo.scala | 36 ++ .../constructive/eo/schemes/zoo/Hylo.scala | 16 + .../constructive/eo/schemes/zoo/Scatter.scala | 66 ---- .../constructive/eo/schemes/ChronoSpec.scala | 115 ++++++ .../eo/schemes/DecorationsSpec.scala | 47 --- .../constructive/eo/schemes/FusionSpec.scala | 126 ++++--- .../eo/schemes/GatherScatterLawsSpec.scala | 213 ----------- .../eo/schemes/SchemesConcurrencySpec.scala | 112 ------ .../eo/schemes/SchemesLawsSpec.scala | 124 ------- .../eo/schemes/SchemesMSpec.scala | 176 ---------- .../constructive/eo/schemes/SchemesSpec.scala | 194 +++------- .../eo/schemes/SchemesZooSpec.scala | 196 ----------- .../dev/constructive/eo/schemes/ZooSpec.scala | 106 ++++++ .../eo/schemes/proto/ProtoFusionSpec.scala | 58 --- .../eo/schemes/samples/Samples.scala | 17 - 24 files changed, 677 insertions(+), 1731 deletions(-) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala delete mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala delete mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala delete mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Hylo.scala delete mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala delete mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/DecorationsSpec.scala delete mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala delete mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala delete mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala delete mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala delete mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala delete mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/proto/ProtoFusionSpec.scala delete mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/samples/Samples.scala diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala index 9b0fe819..7e839731 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -197,7 +197,7 @@ private[schemes] object Machines: private def heapWalk[F[_], N, R]( root: N, expandOr: N => Either[R, F[N]], - combine: (N, F[N], Array[Slot[N, R]]) => R, + combine: (N, F[R]) => R, )(using F: Traverse[F]): R = @tailrec def loop(op: Op[N], pending: Pending[R], stack: List[Frame[F, N, R]]): R = @@ -205,7 +205,7 @@ private[schemes] object Machines: 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) + if slots.length == 0 then loop(Ascend, combine(n, rebuildLayer(layer, slots)), stack) else loop(childAt(slots(0)), NoResult, new Frame(n, layer, slots, 0) :: stack) transparent inline def bubble: R = stack match @@ -214,7 +214,7 @@ private[schemes] object Machines: 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) + else loop(Ascend, combine(fr.node, rebuildLayer(fr.layer, fr.slots)), rest) op match case Ascend => bubble @@ -223,15 +223,15 @@ private[schemes] object Machines: 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), then calls `combine` with the - * node, its layer `F[N]`, and the children's results (positional, `Foldable` order). - * `< [[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). + * 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[N], Array[Slot[N, R]]) => R, + combine: (N, F[R]) => R, )(using F: Traverse[F]): N => R = def rec(n: N, depth: Int): R = @@ -243,7 +243,7 @@ private[schemes] object Machines: while i < slots.length do slots(i) = rec(childAt(slots(i)), depth + 1) i += 1 - combine(n, layer, slots) + combine(n, rebuildLayer(layer, slots)) n => rec(n, 0) @@ -254,12 +254,11 @@ private[schemes] object Machines: */ private[schemes] def foldLayeredOr[F[_], N, R]( expandOr: N => Either[R, F[N]], - combine: (F[N], Array[Slot[N, R]]) => R, + combine: F[R] => R, )(using F: Traverse[F]): N => R = def rec(n: N, depth: Int): R = - if depth >= OnStackLimit then - heapWalk(n, expandOr, (_, layer, slots) => combine(layer, slots)) + if depth >= OnStackLimit then heapWalk(n, expandOr, (_, fr) => combine(fr)) else expandOr(n) match case Left(r) => r // graft: finished, by reference @@ -269,7 +268,7 @@ private[schemes] object Machines: while i < slots.length do slots(i) = rec(childAt(slots(i)), depth + 1) i += 1 - combine(layer, slots) + combine(rebuildLayer(layer, slots)) n => rec(n, 0) @@ -307,7 +306,7 @@ private[schemes] object Machines: */ private[schemes] def foldLayeredM[M[_], F[_], N, R]( expandOr: N => M[Either[R, F[N]]], - combine: (N, F[N], Array[Slot[N, R]]) => M[R], + combine: (N, F[R]) => M[R], )(using M: Monad[M], F: Traverse[F]): N => M[R] = n0 => M.flatMap(M.unit) { _ => @@ -326,7 +325,7 @@ private[schemes] object Machines: 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, layer, slots))(bubbled) + M.map(combine(n, rebuildLayer(layer, slots)))(bubbled) else stack.push(new Frame(n, layer, slots, 0)) M.pure(Left(childAt(slots(0)))) @@ -341,7 +340,7 @@ private[schemes] object Machines: 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, fr.layer, fr.slots)) { r => + M.map(combine(fr.node, rebuildLayer(fr.layer, fr.slots))) { r => val _ = stack.pop() bubbled(r) } diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala new file mode 100644 index 00000000..b505e599 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala @@ -0,0 +1,48 @@ +package dev.constructive.eo +package schemes + +import accessor.{Accessor, ReverseAccessor} + +/** The recursion-scheme carrier. At the value level `Scheme[X, A] = A` (the focus); `X` is a phantom + * at runtime but a *load-bearing type-level index* at the optic level — it is the thesis of this + * module made into a type. + * + * ==Why a distinct carrier (not `Direct`)== + * + * The plain `Direct`-backed [[dev.constructive.eo.optics.Getter]] / [[dev.constructive.eo.optics.Review]] + * collapse a scheme to an opaque closure (`S => A` / `Seed => S`), throwing the algebra away. Once + * the `coalg`/`alg` are gone the `project ∘ embed = id` cancellation that deforestation rides on is + * *invisible*, so `ana.cross(cata)` over `Direct` can only materialise the whole `S` and then fold + * it. The scheme citizens ([[Cata]], [[Ana]]) instead **carry their (co)algebra**, so the fused + * [[Ana.cross]] can rebuild the one-pass machine — `hylo` with no intermediate `S`. + * + * ==The existential `X` is the index== + * + * `X` records what structure a scheme *retains* — the soundness condition for fusion: + * + * - [[Cata]] : `X = Nothing` — a node-blind fold (`alg: F[A] => A`). It retains nothing of the + * source tree, so `ana.cross(cata)` is sound to **fuse** (deforest). + * - [[Ana]] : `X = S` — the unfold threads the built structure. + * + * Refining `X` upward (e.g. `F[(S, A)]` for a paramorphism, `Attr[F, A]` for a histomorphism) trades + * allocation for capability; the `(co)free (co)monads` are the universal such indices. Those are the + * *next* schemes — this file is the node-blind spine (cata / ana / hylo). + * + * Value-identical to `Direct`; kept separate so the scheme optics are honest citizens and so the + * fused [[Ana.cross]] / [[Ana.andThen]] members are reachable in overload resolution. + */ +opaque type Scheme[X, A] = A + +object Scheme: + + inline def apply[X, A](a: A): Scheme[X, A] = a + + extension [X, A](s: Scheme[X, A]) inline def value: A = s + + /** Reads the focus out — powers `.get` on [[Cata]] / [[Hylo]]. */ + given Accessor[Scheme] with + def get[X, A](fa: Scheme[X, A]): A = fa + + /** Wraps a focus in — powers `.reverseGet` on [[Ana]]. */ + given ReverseAccessor[Scheme] with + def reverseGet[X, A](a: A): Scheme[X, A] = a 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 3978cee0..c3488c23 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -1,46 +1,53 @@ package dev.constructive.eo package schemes -import cats.{Monad, Traverse} +import cats.Traverse import data.{Forget, ForgetK} -import optics.{Getter, Optic, Review} -import zoo.* +import optics.Optic +import zoo.{Ana, Attr, Cata, Coattr, Futu, Histo, Hylo} /** 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` — - * algebras pattern-match `F`'s *named constructors*, no positional indexing. + * `Traverse[F]`) and the [[Basis]] (`Project`/`Embed`) correspondence to the recursive type `S`. * - * - [[cata]] folds (`Getter[S, A]`); [[ana]] unfolds (`Review[S, Seed]`) — the build-only mirror - * of `cata`'s read, the Getter↔Review duality. So the materialising hylo is `ana.cross(cata) : - * Getter[Seed, A]` — exactly the build-output⇄read-input seam `Optic.cross` is named for. - * [[hylo]] is the **fused** zero-`S` refold (builds no intermediate `S`); `ana.cross(cata) == - * hylo` is the hylo law. - * - The zoo: [[para]] (subterms paired from the walked nodes), [[apo]] (O(1) graft), [[histo]] / - * [[futu]] (course-of-value / multi-layer, via [[Attr]] / [[Coattr]]). Decorated generically - * through the [[Gather]]/[[Scatter]] decoration optics (over the `BiAffine` carrier) — - * zygo/dyna/chrono are user-written [[Gather]] / [[Scatter]] values fed to the generic - * [[cata]] / [[ana]] overloads. - * - The M-generic drivers [[cataM]] / [[anaM]] / [[hyloM]] run the same machine lifted through - * `Monad[M].tailRecM` (effectful layers, single-pass linear Ms). + * ==The thesis== * - * All drivers run on one stack-safe engine family: a `< 512`-deep on-stack fast path falling back - * per deep subtree to a heap `ArrayDeque` machine ([[Machines.foldLayered]] and siblings) — - * stack-safe to 10⁶, tested. + * A recursion scheme is an [[Optic]] whose existential `X` (see [[Scheme]]) 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 | + * | [[histo]] | [[zoo.Attr]] = `νX. A × F[X]` | the **cofree comonad** (course-of-value fold) | + * | [[ana]] | `S` | the materialising unfold | + * | [[futu]] | [[zoo.Coattr]] = `μX. A + F[X]` | the **free monad** (multi-layer unfold) | + * + * `histo` refines `cata`'s index up the comonad tower; `futu` refines `ana`'s up the monad tower. + * + * ==hylo is the fusion, not a primitive== + * + * [[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** over + * the [[Scheme]] carrier — which keeps the `coalg`/`alg` alive — into [[hylo]], building *no + * intermediate `S`*. The [[FusionSpec]] pins the hylo law and witnesses the deforestation (the + * fused refold never calls `project`/`embed`). + * + * 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. + * + * The citizen classes live in [[zoo]] ([[zoo.Cata]] / [[zoo.Ana]] / [[zoo.Hylo]] / [[zoo.Histo]] / + * [[zoo.Futu]]); this object is the user-facing constructor surface plus [[fLayer]]. */ object Schemes: /** The single *layer* optic for a pattern functor `F`: `project`/`embed` worn as the existing * [[dev.constructive.eo.data.Forget]] carrier. `to = project: S => F[S]`, `from = embed: F[S] => - * S`, so it is a genuine `Optic[S, S, S, S, Forget[F]]` with **no change to the `Optic` trait**. - * - * It is a single-layer *peel/glue* (like `Plated`'s `plate`, but one layer, not the recursion). - * The recursive schemes below drive `to`/`from` themselves and return `Direct`-carried optics, - * so `fLayer` is mainly the concrete proof that a typed `F` is an optic carrier, plus an - * observational read: given `Foldable[F]` it reads its layer's foci via `.foldMap`. Note it does - * NOT compose as freely as `plate` (a `Traversal`): same-carrier `andThen` over `Forget[F]` - * needs `Monad[F]`, which most pattern functors are not — so `fLayer` is a one-layer lens on the - * structure, not a composable traversal. + * S`, so it is a genuine `Optic[S, S, S, S, Forget[F]]` with **no change to the `Optic` trait** + * — the concrete proof that a typed `F` is an optic carrier, and an observational read (given + * `Foldable[F]`) of a layer's immediate foci via `.foldMap`. It is a single-layer peel/glue + * (like `Plated`'s `plate`, but one layer, not the recursion); the schemes below drive + * `to`/`from` themselves. */ def fLayer[F[_], S](using Project[F, S], Embed[F, S]): Optic[S, S, S, S, Forget[F]] = new FLayer[F, S] @@ -52,229 +59,71 @@ object Schemes: def to(s: S): Forget[F][X, S] = ForgetK(P.project(s)) def from(fs: Forget[F][X, S]): S = E.embed(fs.value) - /** Catamorphism over a typed pattern functor `F`, as a composable `Getter`. `alg` sees the - * original node `S` (paramorphism-flavored) plus its already-folded children as a typed `F[A]`. - * Pure `F[A] => A` folds ignore the `S`. Stack-safe to arbitrary depth (the - * [[Machines.foldLayered]] machine, not a trampoline). Requires `Project[F, S]` (to peel each - * layer) and `Traverse[F]` (any lawful instance — the machine, not the user's `foldRight`, - * provides stack-safety). - */ - def cata[F[_], S, A]( - alg: (S, F[A]) => A - )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = - Getter[S, A]( - Machines.foldLayered[F, S, A]( - P.project, - (s, fs, out) => alg(s, Machines.rebuildLayer[F, S, A](fs, out)), - ) - ) - - /** Generalized (decorated) catamorphism — the gcata of the typed path, with the decoration - * supplied as a [[Gather]] optic value. Interior nodes apply `gather ∘ galg`; the **root applies - * `galg` alone** (droste's `gcata` shape). The driver calls [[Gather.gather]] directly — fully - * typed, no per-node carrier wrappers and no dispatch: the undecorated fold has its own overload - * above (the fast path), and `cata(Gather.cata)(galg)` is law-pinned equal to it. `histo` is the - * [[Gather.histo]] instance; user-written decorations (zygo, dyna, …) plug in the same way. - * - * (type-param order: `[F, S, W, A]` — compare [[ana]] `[F, A, W, S]`, which mirrors these in - * input-before-output order: `A` is the input seed there, `S` the built output.) + /** Catamorphism — a **node-blind** fold `alg: F[A] => A`, worn as a [[zoo.Cata]] (`X = Nothing`). + * The algebra sees only the already-folded children as a typed `F[A]` (named constructors — no + * positional `AnyRef` indexing), never the original node `S`; that blindness is what makes + * `ana.cross(cata)` sound to fuse. Consumed via `.get`. Stack-safe. */ - def cata[F[_], S, W, A]( - gather: Gather[F, W, A] - )(galg: (S, F[W]) => A)(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = - val toW: S => W = Machines.foldLayered[F, S, W]( - P.project, - (s, fs, out) => - val fw = Machines.rebuildLayer[F, S, W](fs, out) - gather.gather(fw, galg(s, fw)), - ) - Getter[S, A] { s => - val layer = P.project(s) - galg(s, F.map(layer)(toW)) - } + 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 over a typed pattern functor `F` — each child slot pairs the **original subterm** - * with its folded result. Native route: the machine already walks real `S` nodes and keeps each - * frame's projected layer, so subterms are paired positionally — no per-node re-`embed` - * (droste's `Gather.para` must reconstruct the subterm it threw away). Stack-safe (the - * [[Machines.foldLayered]] machine). + /** Histomorphism — a course-of-value fold `alg: F[Attr[F, A]] => A`, worn as a [[zoo.Histo]] (`X = + * Attr[F, A]`, the cofree comonad). Each child slot carries its full decorated history + * ([[zoo.Attr]]: result + that child's own decorated layer), so the algebra can read arbitrarily + * far down. Consumed via `.get`. Stack-safe; retains O(n) `Attr` cells by nature. */ - def para[F[_], S, A]( - alg: (S, F[(S, A)]) => A - )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = - Getter[S, A]( - Machines.foldLayered[F, S, A]( - P.project, - (s, fs, out) => alg(s, Machines.rebuildLayerPaired[F, S, A](fs, out)), - ) - ) + 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) - /** Histomorphism over a typed pattern functor `F` — the algebra sees each child's **full - * decorated history** ([[Attr]]: result + that child's own decorated layer). - * - * Native route: the combine builds the `Attr` directly, the root projects its head — one less - * dispatch than the generic [[Gather.histo]] route (whose `Step` is EA-elided: B/op identical; - * law-pinned equal in `DecorLawsSpec`). The remaining gap to droste's histo (558k vs 361k B/op - * on the 8k-node fixture) is the stack-safe machine's per-node child array — droste's zoo - * recursion is stack-UNSAFE plain recursion; the ~24 B/node is the price of the guarantee. - * - * Space honesty: course-of-value recursion retains O(n) `Attr` cells by nature. + /** Anamorphism — an unfold `coalg: Seed => F[Seed]`, worn as an [[zoo.Ana]] (`X = S`). Each step + * yields one typed layer of child seeds; [[Embed]] glues each layer into the built `S`. + * Materialising — the built `S` is O(nodes). Consumed via `.reverseGet`. Compose onto a fold + * with `ana.cross(cata)` (the fused build⇄read seam — that is [[hylo]]). Stack-safe. */ - def histo[F[_], S, A]( - alg: (S, F[Attr[F, A]]) => A - )(using F: Traverse[F], P: Project[F, S]): Getter[S, A] = - val toAttr: S => Attr[F, A] = Machines.foldLayered[F, S, Attr[F, A]]( - P.project, - (s, fs, out) => - val layer = Machines.rebuildLayer[F, S, Attr[F, A]](fs, out) - Attr(alg(s, layer), layer), - ) - Getter[S, A](s => toAttr(s).head) + 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) - /** Anamorphism over a typed pattern functor `F`, as a `Review` (`reverseGet: Seed => S`) — the - * build-only mirror of [[cata]]'s read. The single fused coalgebra `Seed => F[Seed]` yields one - * typed layer of child seeds; [[Embed]] assembles each layer into the built `S`. Materializing — - * the built `S` is O(nodes). Stack-safe (the [[Machines.foldLayered]] machine). Requires - * `Embed[F, S]` and `Traverse[F]`. Type params are `[F, Seed, S]` (input before output) to match - * [[hylo]]. Compose onto a fold with `ana.cross(cata)` (the build⇄read seam). + /** Futumorphism — a multi-layer unfold `coalg: A => F[Coattr[F, A]]`, worn as a [[zoo.Futu]] (`X = + * Coattr[F, A]`, the free monad). Each slot answers [[zoo.Coattr.Pure]] (keep unfolding) or + * [[zoo.Coattr.Roll]] (a prebuilt layer, no coalgebra call), so one step may emit several + * layers. Consumed via `.reverseGet`. Stack-safe. */ - def ana[F[_], Seed, S]( - coalg: Seed => F[Seed] - )(using F: Traverse[F], E: Embed[F, S]): Review[S, Seed] = - Review[S, Seed]( - Machines.foldLayered[F, Seed, S]( - coalg, - (_, fSeed, out) => E.embed(Machines.rebuildLayer[F, Seed, S](fSeed, out)), - ) - ) - - /** Apomorphism over a typed pattern functor `F` — per child slot the coalgebra answers - * `Right(seed)` (keep unfolding) or `Left(s)` (an **already-finished subtree**). Native O(1) - * graft: `Left` subtrees are prefilled into their result slots **by reference** — never - * recursed, never projected ([[Machines.foldLayeredOr]]). Contrast droste's scatter-apo, which - * re-walks grafts through `project` (O(graft) per graft — the distApo route, kept only as a law - * fixture in the test suite). Stack-safe. + 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) + + /** Hylomorphism — the **fused** refold `Seed => A`, building **no intermediate `S`** (so it needs + * neither `Project` nor `Embed`, only `Traverse[F]`). Definitionally + * `ana(coalg).cross(cata(alg))` — this constructor builds the same one-pass machine directly for + * callers who never name the intermediate type. `coalg` unfolds a seed into one typed layer; + * `alg` folds the layer's results to `A` (node-blind, like [[cata]]). Stack-safe. */ - def apo[F[_], A, S]( - coalg: A => F[Either[S, A]] - )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = - val run = Machines.foldLayeredOr[F, Either[S, A], S]( - { - case Left(s) => Left(s) - case Right(a) => Right(coalg(a)) - }, - (fw, out) => E.embed(Machines.rebuildLayer[F, Either[S, A], S](fw, out)), - ) - Review[S, A](a => run(Right(a))) - - /** Futumorphism over a typed pattern functor `F` — the coalgebra may emit **multiple layers per - * step** ([[Coattr]]: `Pure` keeps unfolding, `Roll` is a prebuilt layer unrolled with no - * coalgebra call). + def hylo[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))) + + /** Chronomorphism — the **fused** futu-then-histo refold `A => B`, **hylo lifted to the universal + * indices**: it unfolds through the free monad ([[zoo.Coattr]]) and folds through the cofree + * comonad ([[zoo.Attr]]), building **no intermediate `S`**. Definitionally `futu(coalg).cross( + * histo(algebra))`; this constructor builds the same one-pass machine directly. * - * Native route: the expand matches `Coattr` directly — one less dispatch than the generic - * [[Scatter.futu]] route (whose per-slot `Step` is EA-elided: B/op identical; law-pinned equal - * in `DecorLawsSpec`). The gap to droste's futu (655k vs 459k B/op) is the stack-safe machine's - * per-node child array — droste's zoo recursion is stack-unsafe. + * Like [[hylo]] it needs **only `Traverse[F]`** — no `Project`, no `Embed` — which is the + * compile-time deforestation proof: with no `Basis` in scope there is no `S` to build. The + * `Coattr` (multi-layer input) and `Attr` (course-of-value history) cells are threaded + * internally; only the root head is read out. Heads-only `algebra` + all-`Pure` `coalg` + * degenerate to [[hylo]]. Stack-safe (the [[Machines.foldLayered]] machine); retains O(n) `Attr` + * cells by nature. */ - def futu[F[_], A, S]( - coalg: A => F[Coattr[F, A]] - )(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = + def chrono[F[_], A, B]( + coalg: A => F[Coattr[F, A]], + algebra: F[Attr[F, B]] => B, + )(using F: Traverse[F]): Hylo[A, B] = val expand: Coattr[F, A] => F[Coattr[F, A]] = case Coattr.Pure(a) => coalg(a) case Coattr.Roll(layer) => layer - val build = Machines.foldLayered[F, Coattr[F, A], S]( - expand, - (_, fw, out) => E.embed(Machines.rebuildLayer[F, Coattr[F, A], S](fw, out)), - ) - Review[S, A](a => build(Coattr.Pure(a))) - - /** Generalized (decorated) anamorphism — the gana of the typed path, with the decoration supplied - * as a [[Scatter]] optic value. Each `W` slot is scattered ([[Scatter.scatter]], called directly - * — fully typed, no per-node carrier wrappers and no dispatch: the undecorated unfold has its - * own overload above, and `ana(Scatter.ana)(gcoalg)` is law-pinned equal to it): `Right(seed)` - * calls `gcoalg`, `Left(layer)` unrolls the prebuilt layer with **no coalgebra call**. The root - * seed enters through the decoration's pointed unit ([[Scatter.unit]] — gana's `pure`). `futu` - * is the [[Scatter.futu]] instance. (apo has no shipped Scatter value — distApo is inferior by - * construction; the O(1) graft belongs to the native `apo` engine.) - * - * (type-param order: compare [[cata]] `[F, S, W, A]` — the fold mirror swaps `Seed`/`A`.) - */ - def ana[F[_], A, W, S]( - scatter: Scatter[F, W, A] - )(gcoalg: A => F[W])(using F: Traverse[F], E: Embed[F, S]): Review[S, A] = - val expand: W => F[W] = w => - scatter.scatter(w) match - case Right(a) => gcoalg(a) - case Left(layer) => layer - val build: W => S = Machines.foldLayered[F, W, S]( - expand, - (_, fw, out) => E.embed(Machines.rebuildLayer[F, W, S](fw, out)), - ) - Review[S, A](a => build(scatter.unit(a))) - - /** Hylomorphism over a typed pattern functor `F` — the **fused** refold `Seed => A`, building - * **no intermediate `S`** (so it needs neither `Project` nor `Embed`, only `Traverse[F]`). - * `coalg` unfolds a seed into one typed layer; `alg` folds the layer's results to `A` (the seed - * is supplied, paramorphism-flavored). Stack-safe (the [[Machines.foldLayered]] machine). Equal - * to the materialising `ana(coalg).cross(cata(alg))` for a *pure* algebra (the hylo law) — - * `hylo` fuses it into one pass with no intermediate `S`; for a node-reading para algebra the - * two agree only under the seed↔`embed(coalg(seed))` correspondence. - */ - def hylo[F[_], Seed, A]( - coalg: Seed => F[Seed], - alg: (Seed, F[A]) => A, - )(using F: Traverse[F]): Getter[Seed, A] = - Getter[Seed, A]( - Machines.foldLayered[F, Seed, A]( - coalg, - (seed, fSeed, out) => alg(seed, Machines.rebuildLayer[F, Seed, A](fSeed, out)), - ) - ) - - /** Effectful catamorphism — the algebra runs in `M` (`(S, F[A]) => M[A]`); the layer peel stays - * the pure `Project`. Returns the `Forget[M]`-carried [[zoo.FoldM]] citizen; consume via `.run`. - */ - def cataM[M[_], F[_], S, A]( - algM: (S, F[A]) => M[A] - )(using M: Monad[M], F: Traverse[F], P: Project[F, S]): FoldM[M, S, A] = - new FoldM[M, S, A]( - Machines.foldLayeredM[M, F, S, A]( - s => M.pure(Right(P.project(s))), - (s, fs, out) => algM(s, Machines.rebuildLayer[F, S, A](fs, out)), - ) - ) - - /** Effectful anamorphism — the coalgebra (the layer producer) runs in `M` (`Seed => M[F[Seed]]`, - * the arbo `GetSellOptions` shape: fetching children is effectful). - * - * '''M-rung encoding (not a `Review`).''' On the pure rung [[ana]] is a `Review` (a build, the - * dual of [[cata]]'s `Getter`). The M effect, though, only fits the Kleisli *read* slot of - * `Forget[M]` (`to: Seed => M[S]`) — there is no carrier for "build with effect on the output" — - * so `anaM` is a [[zoo.FoldM]] `Seed => M[S]`, a Kleisli arrow that is *semantically* a build. - * The read/build duality of the pure rung collapses into Kleisli arrows here, and composition is - * `anaM.andThen(cataM)` (Kleisli `flatMap`, the materialising effectful hylo) rather than the - * pure rung's `cross`; `hyloM` is the fused one. Consume via `.run`. - */ - def anaM[M[_], F[_], Seed, S]( - coalgM: Seed => M[F[Seed]] - )(using M: Monad[M], F: Traverse[F], E: Embed[F, S]): FoldM[M, Seed, S] = - new FoldM[M, Seed, S]( - Machines.foldLayeredM[M, F, Seed, S]( - seed => M.map(coalgM(seed))(Right(_)), - (_, fSeed, out) => M.pure(E.embed(Machines.rebuildLayer[F, Seed, S](fSeed, out))), - ) - ) - - /** Effectful hylomorphism — the always-fused M spelling (what the D6 `eoHyloM` bench row runs): - * `Seed => M[A]` with **no intermediate `S`**, seed-typed algebra. - */ - def hyloM[M[_], F[_], Seed, A]( - coalgM: Seed => M[F[Seed]], - algM: (Seed, F[A]) => M[A], - )(using M: Monad[M], F: Traverse[F]): FoldM[M, Seed, A] = - new FoldM[M, Seed, A]( - Machines.foldLayeredM[M, F, Seed, A]( - seed => M.map(coalgM(seed))(Right(_)), - (seed, fSeed, out) => algM(seed, Machines.rebuildLayer[F, Seed, A](fSeed, out)), + val build: Coattr[F, A] => Attr[F, B] = + Machines.foldLayered[F, Coattr[F, A], Attr[F, B]]( + expand, + (_, layer) => Attr(algebra(layer), layer), ) - ) + new Hylo[A, B](a => Attr.forget(build(Coattr.Pure(a)))) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala deleted file mode 100644 index af2730a4..00000000 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/proto/SchemeCarrier.scala +++ /dev/null @@ -1,145 +0,0 @@ -package dev.constructive.eo -package schemes -package proto - -import cats.Traverse - -import accessor.{Accessor, ReverseAccessor} -import optics.Optic - -// =========================================================================================== -// PROTOTYPE — "the honest hylo optic": schemes as X-indexed optics that fuse at `cross`. -// -// The motivating question (PR #24 follow-up): can `ana.cross(cata)` be as cheap as `hylo` -// (no intermediate `S`) instead of materialising the whole tree? The plain `Direct` -// `Getter`/`Review` CANNOT — they have collapsed to opaque `Seed => S` / `S => A` closures, -// throwing away the `coalg`/`alg`, so the `project ∘ embed = id` cancellation that fusion -// rides on is invisible. Fusion needs the (co)algebra carried. -// -// This prototype carries it on a dedicated carrier `Scheme` (value-level identical to -// `Direct`, but a DISTINCT opaque type so the scheme optics are honest citizens — not -// `Getter`/`Review` clones — and so the fused `cross` overload is reachable). The existential -// `X` records the fold's STRUCTURAL DEPENDENCY, which is exactly the soundness condition for -// deforestation: -// -// cata : X = Nothing node-BLIND fold (F[A] => A) → cross FUSES (no S, hylo machine) -// para : X = S node-READING fold ((S, F[A]) => A) → cross MATERIALISES (needs the S) -// -// "ana.cross(cata) fused vs materialising are the same optic at two X-resolutions" -// (docs/brainstorms/2026-06-12-existential-x-is-the-decoration.md) made operational and SOUND: -// the resolution is forced by whether the algebra reads its node. The fused runtime is just -// `Machines.foldLayered(coalg, alg)` — i.e. `hylo` — so the win is already proven; the novelty -// here is purely the type encoding that lets `cross` pick it. -// =========================================================================================== - -/** The scheme carrier: `Scheme[X, A] = A` (the focus; `X` is a phantom at the value level, a - * type-level tag at the optic level). Distinct from `Direct` on purpose — see the banner. - */ -opaque type Scheme[X, A] = A - -object Scheme: - inline def apply[X, A](a: A): Scheme[X, A] = a - extension [X, A](s: Scheme[X, A]) inline def value: A = s - - given Accessor[Scheme] with - def get[X, A](fa: Scheme[X, A]): A = fa - - given ReverseAccessor[Scheme] with - def reverseGet[X, A](a: A): Scheme[X, A] = a - -/** Pure catamorphism — a node-BLIND fold `alg: F[A] => A`. `X = Nothing`: it retains nothing of the - * structure, so `ana.cross(this)` is sound to fuse. Read-only (`.get` via `Accessor[Scheme]`, no - * `asGetter`). - */ -final class SchemeCata[F[_], S, A](private[proto] val alg: F[A] => A)(using - private[proto] val F: Traverse[F], - private[proto] val P: Project[F, S], -) extends Optic[S, Unit, A, Unit, Scheme]: - type X = Nothing - - private val run: S => A = - Machines.foldLayered[F, S, A]( - P.project, - (_, fs, out) => alg(Machines.rebuildLayer[F, S, A](fs, out)), - ) - - def to(s: S): Scheme[X, A] = Scheme(run(s)) - def from(b: Scheme[X, Unit]): Unit = () - -/** Paramorphism — a node-READING fold `alg: (S, F[A]) => A`. `X = S`: it can read the original - * subterm, so it genuinely needs the materialised tree — `ana.cross(this)` MUST build the `S`. - */ -final class SchemePara[F[_], S, A](private[proto] val alg: (S, F[A]) => A)(using - private[proto] val F: Traverse[F], - private[proto] val P: Project[F, S], -) extends Optic[S, Unit, A, Unit, Scheme]: - type X = S - - private[proto] val run: S => A = - Machines.foldLayered[F, S, A]( - P.project, - (s, fs, out) => alg(s, Machines.rebuildLayer[F, S, A](fs, out)), - ) - - def to(s: S): Scheme[X, A] = Scheme(run(s)) - def from(b: Scheme[X, Unit]): Unit = () - -/** The composite `ana.cross(_)` result — a forward read `Seed => A`. Whether `run` is the fused - * one-pass machine or a materialise-then-fold depends on which `cross` overload built it. - */ -final class SchemeGetter[Seed, A](private[proto] val run: Seed => A) - extends Optic[Seed, Unit, A, Unit, Scheme]: - type X = Nothing - def to(s: Seed): Scheme[X, A] = Scheme(run(s)) - def from(b: Scheme[X, Unit]): Unit = () - -/** Anamorphism — build `reverseGet: Seed => S`. `X = S` (the structure it threads). Build-only - * (`.reverseGet` via `ReverseAccessor[Scheme]`). Carries `coalg` so `cross` can fuse. - */ -final class SchemeAna[F[_], Seed, S](private[proto] val coalg: Seed => F[Seed])(using - private[proto] val F: Traverse[F], - private[proto] val E: Embed[F, S], -) extends Optic[Unit, S, Unit, Seed, Scheme]: - type X = S - - private[proto] val build: Seed => S = - Machines.foldLayered[F, Seed, S]( - coalg, - (_, fSeed, out) => E.embed(Machines.rebuildLayer[F, Seed, S](fSeed, out)), - ) - - def to(u: Unit): Scheme[X, Unit] = Scheme(()) - def from(b: Scheme[X, Seed]): S = build(Scheme.value(b)) - - /** FUSED seam — `cata` is node-blind (`X = Nothing`), so deforestation is sound: rebuild the - * single-pass hylo machine from this `coalg` + `cata.alg`. No intermediate `S`. - */ - def cross[A](cata: SchemeCata[F, S, A]): SchemeGetter[Seed, A] = - new SchemeGetter[Seed, A]( - Machines.foldLayered[F, Seed, A]( - coalg, - (_, fSeed, out) => cata.alg(Machines.rebuildLayer[F, Seed, A](fSeed, out)), - )(using F) - ) - - /** MATERIALISING seam — `para` reads its node (`X = S`), so the tree is genuinely needed: build - * the `S`, then fold it. Same `cross` spelling; the overload (driven by the argument's `X`) - * picks this branch. - */ - def cross[A](para: SchemePara[F, S, A]): SchemeGetter[Seed, A] = - new SchemeGetter[Seed, A](seed => para.run(build(seed))) - -/** Prototype constructors mirroring `Schemes.{cata, para, ana}` but on the `Scheme` carrier. */ -object Proto: - - def cata[F[_], S, A](alg: F[A] => A)(using Traverse[F], Project[F, S]): SchemeCata[F, S, A] = - new SchemeCata[F, S, A](alg) - - def para[F[_], S, A](alg: (S, F[A]) => A)(using Traverse[F], Project[F, S]): SchemePara[F, S, A] = - new SchemePara[F, S, A](alg) - - def ana[F[_], Seed, S](coalg: Seed => F[Seed])(using - Traverse[F], - Embed[F, S], - ): SchemeAna[F, Seed, S] = - new SchemeAna[F, Seed, S](coalg) 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..c98e1d87 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala @@ -0,0 +1,36 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import optics.Optic + +/** Anamorphism citizen — an unfold worn as an optic over [[Scheme]] with `X = S` (the structure it + * threads). `Review`-shaped (`Optic[Unit, S, Unit, Seed, Scheme]`): the build `from` runs the + * unfold; the read side is vestigial. Carries `coalg` so [[cross]] can fuse with a node-blind + * [[Cata]]. Refining `X` upward to [[Coattr]] = `μX. Seed + F[X]` (the free monad) gives the + * multi-layer unfold (futumorphism — see [[Futu]]). + */ +final class Ana[F[_], Seed, S](private[zoo] val coalg: Seed => F[Seed])(using + F: Traverse[F], + E: Embed[F, S], +) extends Optic[Unit, S, Unit, Seed, Scheme]: + type X = S + + private val build: Seed => S = + Machines.foldLayered[F, Seed, S](coalg, (_, fr) => E.embed(fr)) + + def to(u: Unit): Scheme[X, Unit] = Scheme(()) + def from(b: Scheme[X, Seed]): S = build(Scheme.value(b)) + + /** The fused hylo seam: ana ∘ a **node-blind** [[Cata]]. Because the fold retains nothing of the + * tree (`X = Nothing`), deforestation is sound — rebuild the one-pass [[Machines.foldLayered]] + * machine from `this.coalg` + `cata.alg`, building **no intermediate `S`**. This is what makes + * `ana.cross(cata)` *be* [[Schemes.hylo]] rather than a materialise-then-fold. + * + * A member (not the generic `Optic.cross` extension, which would `reverse.andThen` into a + * materialising read) so it wins overload resolution and the fusion is the default. + */ + def cross[B](cata: Cata[F, S, B]): Hylo[Seed, B] = + new Hylo[Seed, B](Machines.foldLayered[F, Seed, B](coalg, (_, fr) => cata.alg(fr))(using F)) 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..34151575 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala @@ -0,0 +1,29 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import optics.Optic + +/** Catamorphism citizen — a **node-blind** fold worn as an optic over [[Scheme]] with `X = Nothing`, + * the forgetful (trivial) resolution of the recursion index. `Getter`-shaped (`Optic[S, Unit, A, + * Unit, Scheme]`): the read `to` runs the fold; the build side is vestigial. 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, a lawful Lens) and [[Attr]] = + * `νX. A × F[X]` (histomorphism, the cofree comonad — see [[Histo]]). + */ +final class Cata[F[_], S, A](private[zoo] val alg: F[A] => A)(using + F: Traverse[F], + P: Project[F, S], +) extends Optic[S, Unit, A, Unit, Scheme]: + type X = Nothing + + private val run: S => A = + Machines.foldLayered[F, S, A](P.project, (_, fr) => alg(fr)) + + def to(s: S): Scheme[X, A] = Scheme(run(s)) + def from(b: Scheme[X, Unit]): Unit = () 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 deleted file mode 100644 index 15fc8830..00000000 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala +++ /dev/null @@ -1,54 +0,0 @@ -package dev.constructive.eo -package schemes -package zoo - -import cats.Monad - -import data.{Forget, ForgetK} -import optics.Optic - -/** Generic effectful-fold citizen: `run: S => M[A]`. What `cataM` / `anaM` / `hyloM` return. - * - * Effectful scheme citizens — the M-generic drivers' return types. Computational steps evolve in a - * `Monad[M]` (the arbo `Calculator` shape: fetching a node's children is effectful), and the - * results are **`Forget[M]`-carried** optics: `S => M[A]` worn as `Optic[S, Unit, A, Unit, - * Forget[M]]`, composing same-carrier through `assocForgetMonad`. - * - * Consumption: effect Ms (IO, State, …) have no `Foldable`, so the Foldable-gated Fold operations - * (`.foldMap`/`.headOption`) and ReadCompose cells do NOT apply — the public consumption surface - * is the stored [[FoldM.run]] (not raw `.to`). An Accessor-into-M capability is follow-up - * material. - * - * 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. See the linear-M boundary - * test in `SchemesMSpec`. A persistent-state variant is deferred until a real consumer needs one. - * - * Re-forcing the same `M[A]` value returned by [[FoldM.run]] is safe — each force allocates its - * own fresh mutable state (the frame deque is allocated inside the `M`, not before it). Concurrent - * forcing of a single `M[A]` value remains unsupported; each `run(s)` call is independent. - * - * Composition: `anaM.andThen(cataM)` Kleisli-chains two `FoldM`s (`M.flatMap`) into the - * materialising effectful hylo (`M[S]` built, then folded); `Schemes.hyloM` is the fused one-pass - * spelling (no `M[S]`). NB the M rung composes via `andThen` (both `cataM` and `anaM` are Kleisli - * arrows `_ => M[_]` — see [[Schemes.anaM]] on why the effect collapses the read/build duality), - * whereas the pure rung composes a `Review` (`ana`) into a `Getter` (`cata`) via `cross`. - * - * `FoldM` is a concrete, public citizen: `cataM` / `anaM` / `hyloM` all return it, and users may - * wrap their own `S => M[A]` as one. - */ -class FoldM[M[_], S, A](val run: S => M[A]) extends Optic[S, Unit, A, Unit, Forget[M]]: - type X = Nothing - - def to(s: S): Forget[M][X, A] = ForgetK(run(s)) - def from(d: Forget[M][X, Unit]): Unit = () - - /** The focus-seam composition of two effectful reads — the **materialising** effectful hylo: - * `run` this `FoldM` fully to `M[A]`, then Kleisli-chain into `inner` (`M.flatMap`). A concrete - * same-type member (mirroring the pure `Getter.andThen`), so it is strictly more specific than - * the generic `Optic.andThen` overloads — `anaM.andThen(cataM)` resolves here and stays a - * `FoldM`, no ascription needed. Requires `Monad[M]` (the effect's bind); `Schemes.hyloM` is the - * fused one-pass spelling that builds no intermediate `M[A]`. - */ - def andThen[C](inner: FoldM[M, A, C])(using M: Monad[M]): FoldM[M, S, C] = - new FoldM[M, S, C](s => M.flatMap(run(s))(inner.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..b4392700 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala @@ -0,0 +1,47 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import optics.Optic + +/** Futumorphism citizen — a multi-layer unfold worn as an optic over [[Scheme]] with **`X = + * Coattr[F, A]`**, the free monad `μX. A + F[X]`. The build-side mirror of [[Histo]]: where the + * cofree comonad is the universal index for folds, the free monad is the universal index for + * unfolds. `Ana` is the same optic at the resolution `X = S`; `Futu` refines it so the coalgebra + * may emit several layers per step. + * + * `coalg: A => F[Coattr[F, A]]` answers each child slot with either [[Coattr.Pure]] (a seed the + * engine keeps unfolding) or [[Coattr.Roll]] (a prebuilt layer unrolled with **no** further + * coalgebra call) — so one step can produce more than one layer of structure. `Review`-shaped + * (`Optic[Unit, S, Unit, A, Scheme]`), consumed via `.reverseGet`; the root seed enters as + * `Coattr.Pure`. Stack-safe (the [[Machines.foldLayered]] machine). An all-`Pure` coalgebra + * (`map(coalg(_))(Coattr.Pure(_))`) degenerates to [[Ana]]. + */ +final class Futu[F[_], A, S](private[zoo] val coalg: A => F[Coattr[F, A]])(using + F: Traverse[F], + E: Embed[F, S], +) extends Optic[Unit, S, Unit, A, Scheme]: + type X = Coattr[F, A] + + private val build: A => S = + val expand: Coattr[F, A] => F[Coattr[F, A]] = + case Coattr.Pure(a) => coalg(a) + case Coattr.Roll(layer) => layer + val run = Machines.foldLayered[F, Coattr[F, A], S](expand, (_, fr) => E.embed(fr)) + a => run(Coattr.Pure(a)) + + def to(u: Unit): Scheme[X, Unit] = Scheme(()) + def from(b: Scheme[X, A]): S = build(Scheme.value(b)) + + /** 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, the intermediate `S` is + * never built (mirrors [[Ana.cross]] for the trivial indices). Delegates to [[Schemes.chrono]], + * which needs only `Traverse[F]` — the `Embed`/`Project` carried by `this`/`histo` go unused. + * + * A member (not the generic `Optic.cross`, which would `reverse.andThen` and materialise) so the + * fused chrono wins overload resolution. + */ + def cross[B](histo: Histo[F, S, B]): Hylo[A, B] = + Schemes.chrono[F, A, B](coalg, histo.alg)(using F) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala deleted file mode 100644 index 432949b3..00000000 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Gather.scala +++ /dev/null @@ -1,83 +0,0 @@ -package dev.constructive.eo -package schemes -package zoo - -import data.BiAffine -import data.BiAffine.{Done, Step} -import optics.Optic - -// =========================================================================================== -// Gather / Scatter — the decoration optics, over the BiAffine carrier. -// -// A generalized scheme's decoration is an optic whose existential leftover is one F-layer -// (the names echo droste's Gather/Scatter and recursion-schemes' distCata/distHisto/...). -// Both are ABSTRACT CLASSES, not type aliases: a decoration is written by extending the -// class and implementing ONE named method (`gather` / `scatter` + `unit`) — the Optic -// `to`/`from` plumbing over BiAffine is implemented once, here, and the generic drivers in -// [[Schemes]] call the named methods directly (no per-node carrier wrappers on the generic -// route). Sides are pinned in the TYPE, eo-style (read-only/build-only citizens): -// -// - Fold side (Gather): build-only. [[Gather.gather]] consumes one decorated layer -// `F[W]` plus the node's result `A` and produces the decoration `W` (histo's gather is -// literally the `Attr` constructor). The optic read side is vestigial (throws, the -// `Unfold.algebra` precedent); `Done` never occurs on this side. -// -// - Unfold side (Scatter): a full citizen. [[Scatter.scatter]] answers each slot with -// `Right(seed)` (call the coalgebra) or `Left(layer)` (a prebuilt `F[W]`; unroll it, no -// coalgebra call — the `Done` arm). [[Scatter.unit]] is the POINTED unit — the seed -// injection `A => W` (gana's `pure`: ana = identity, futu = `Coattr.Pure`), giving the -// unit law `to(from(Step((), a))) == Step((), a)`. -// -// X pinning per shape: gather side X = (Unit, F[W]) (Snd = the one-F-layer context); scatter -// side X = (F[W], Unit) (Fst = the prebuilt-layer Done payload). -// -// para and apo have NO shipped values here: their generic routes are deliberately inferior -// (para's gather would re-embed each subterm — droste's Gather.para; apo's scatter would -// re-walk grafts through Project — distApo, O(graft)), and the native `Schemes.para` / -// `Schemes.apo` engines subsume them. Their decoration semantics survive as law fixtures in -// the test suite (GatherScatterLawsSpec), pinning the native routes to the definitions. -// =========================================================================================== - -/** Fold-side decoration optic: gather-only (build-only member). Extend and implement [[gather]]; - * the `BiAffine` optic surface (`from` = gather on the `Step` arm, vestigial read) is provided - * here. - */ -abstract class Gather[F[_], W, A] extends Optic[Unit, W, Unit, A, BiAffine]: - type X = (Unit, F[W]) - - /** The gather: one decorated layer plus the node's result, to the node's decoration. */ - def gather(layer: F[W], a: A): W - - final def to(u: Unit): BiAffine[X, Unit] = - throw new UnsupportedOperationException( - "Gather is gather-only (build-only): its read side is vestigial by specification" - ) - - final def from(xb: BiAffine[X, A]): W = xb match - case s: Step[X, A] => gather(s.snd, s.b) - case _: Done[X, A] => - throw new UnsupportedOperationException( - "Gather is a fold-side decoration: Done never occurs on the gather seam" - ) - -/** Named fold-side decoration values. */ -object Gather: - - /** The undecorated fold — gather keeps the result, discards the layer (`W = A`). With it, the - * generic `Schemes.cata(gather)(galg)` agrees with the direct `Schemes.cata(galg)` (law-pinned); - * the direct overload IS the fast path, no dispatch involved. - */ - final class Id[F[_], A] extends Gather[F, A, A]: - def gather(layer: F[A], a: A): A = a - - /** @see [[Id]] */ - def cata[F[_], A]: Gather[F, A, A] = new Id[F, A] - - /** Histomorphism decoration — the gather IS the [[Attr]] constructor: each node keeps its result - * plus its children's full decorated histories. - */ - final class Histo[F[_], A] extends Gather[F, Attr[F, A], A]: - def gather(layer: F[Attr[F, A]], a: A): Attr[F, A] = Attr(a, layer) - - /** @see [[Histo]] */ - def histo[F[_], A]: Gather[F, Attr[F, A], A] = new Histo[F, A] 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..31afaba3 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala @@ -0,0 +1,36 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import optics.Optic + +/** Histomorphism citizen — a course-of-value fold worn as an optic over [[Scheme]] with **`X = + * Attr[F, A]`**, the cofree comonad `νX. A × F[X]`. This is the thesis at its sharpest: the + * histomorphism's existential is *literally* the universal index for folds. `Cata` is the same + * optic at the forgetful resolution `X = Nothing`; `Histo` keeps the whole decorated history, so + * `Histo : Cata :: Lens : Getter` — the index refined from the trivial comonad up to the cofree one. + * + * `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), so it can read + * arbitrarily far down — folds unreachable by a single-pass [[Cata]]. `Getter`-shaped (`Optic[S, + * Unit, A, Unit, Scheme]`), consumed via `.get`; the read projects the root's head ([[Attr.forget]]). + * + * Space honesty: course-of-value recursion retains O(n) `Attr` cells by nature. Stack-safe (the + * [[Machines.foldLayered]] machine). Heads-only (`alg ∘ map(_.head)`) degenerates to [[Cata]]. + */ +final class Histo[F[_], S, A](private[zoo] val alg: F[Attr[F, A]] => A)(using + F: Traverse[F], + P: Project[F, S], +) extends Optic[S, Unit, A, Unit, Scheme]: + type X = Attr[F, A] + + private val toAttr: S => Attr[F, A] = + Machines.foldLayered[F, S, Attr[F, A]]( + P.project, + (_, layer) => Attr(alg(layer), layer), + ) + + def to(s: S): Scheme[X, A] = Scheme(Attr.forget(toAttr(s))) + def from(b: Scheme[X, Unit]): Unit = () 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..989d0fde --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Hylo.scala @@ -0,0 +1,16 @@ +package dev.constructive.eo +package schemes +package zoo + +import optics.Optic + +/** Hylomorphism citizen — the fused refold worn as an optic over [[Scheme]] with `X = Nothing`. + * `Getter`-shaped (`Optic[Seed, Unit, A, Unit, Scheme]`), consumed via `.get`. Built by + * [[Ana.cross]] or [[Schemes.hylo]]; carries only the fused `refold`, no tree. Not a primitive — + * it *is* `ana.cross(cata)`. + */ +final class Hylo[Seed, A](private[zoo] val refold: Seed => A) + extends Optic[Seed, Unit, A, Unit, Scheme]: + type X = Nothing + def to(s: Seed): Scheme[X, A] = Scheme(refold(s)) + def from(b: Scheme[X, Unit]): Unit = () diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala deleted file mode 100644 index 013cb5d2..00000000 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Scatter.scala +++ /dev/null @@ -1,66 +0,0 @@ -package dev.constructive.eo -package schemes -package zoo - -import data.BiAffine -import data.BiAffine.{Done, Step} -import optics.Optic - -// The Gather/Scatter family is documented in Gather.scala — see that file for -// the full banner comment covering the BiAffine carrier, X-pinning, and the -// design rationale for para/apo having no shipped Scatter/Gather values. - -/** Unfold-side decoration optic: a full citizen. Extend and implement [[scatter]] (the affine - * match: `Right(seed)` keeps unfolding, `Left(layer)` is prebuilt — no coalgebra call) and - * [[unit]] (the pointed seed injection — gana's `pure`); the `BiAffine` optic surface (`to` = - * scatter, `from` on `Step` = unit) is provided here. - */ -abstract class Scatter[F[_], W, A] extends Optic[W, W, A, A, BiAffine]: - type X = (F[W], Unit) - - /** The scatter: `Right(seed)` → call the coalgebra; `Left(layer)` → unroll as-is. */ - def scatter(w: W): Either[F[W], A] - - /** The pointed unit: inject a seed into the decoration (`Scatter.ana` = identity, `Scatter.futu` = - * `Coattr.Pure`). - */ - def unit(a: A): W - - final def to(w: W): BiAffine[X, A] = scatter(w) match - case Right(a) => new Step[X, A]((), a) - case Left(layer) => new Done[X, A](layer) - - final def from(xb: BiAffine[X, A]): W = xb match - case s: Step[X, A] => unit(s.b) - case _: Done[X, A] => - throw new UnsupportedOperationException( - "Scatter: the pointed unit (from) is inhabited on the Step arm only" - ) - -/** Named unfold-side decoration values. */ -object Scatter: - - /** The undecorated unfold — every slot is a seed; the unit is the identity (`W = A`). With it, - * the generic `Schemes.ana(scatter)(gcoalg)` agrees with the direct `Schemes.ana(gcoalg)` - * (law-pinned); the direct overload IS the fast path. - */ - final class Id[F[_], A] extends Scatter[F, A, A]: - def scatter(w: A): Either[F[A], A] = Right(w) - def unit(a: A): A = a - - /** @see [[Id]] */ - def ana[F[_], A]: Scatter[F, A, A] = new Id[F, A] - - /** Futumorphism decoration — `Pure(seed)` calls the coalgebra, `Roll(layer)` unrolls the prebuilt - * layer without a call; the unit is `Coattr.Pure`. - */ - final class Futu[F[_], A] extends Scatter[F, Coattr[F, A], A]: - - def scatter(w: Coattr[F, A]): Either[F[Coattr[F, A]], A] = w match - case Coattr.Pure(a) => Right(a) - case Coattr.Roll(layer) => Left(layer) - - def unit(a: A): Coattr[F, A] = Coattr.Pure(a) - - /** @see [[Futu]] */ - def futu[F[_], A]: Scatter[F, Coattr[F, A], A] = new Futu[F, A] 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..ee1c726b --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala @@ -0,0 +1,115 @@ +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/DecorationsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/DecorationsSpec.scala deleted file mode 100644 index 775f297a..00000000 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/DecorationsSpec.scala +++ /dev/null @@ -1,47 +0,0 @@ -package dev.constructive.eo -package schemes - -import org.specs2.mutable.Specification - -import schemes.samples.BinF -import zoo.* - -/** Unit checks for the decoration data ([[Attr]] / [[Coattr]]) — construction, projection, and - * structural equality over a real pattern functor. The zoo members that consume them (`histo` / - * `futu`) carry the behavioural coverage. - */ -class DecorationsSpec extends Specification: - - // A decorated branch: results 1 and 3 at the leaves, 4 at the root. - private val decorated: Attr[BinF, Int] = - Attr(4, BinF.BranchF(Attr(1, BinF.LeafF(1)), Attr(3, BinF.LeafF(3)))) - - "Attr (cofree-without-laziness)" should { - - "project its head" in { - decorated.head === 4 - } - - "expose each child's full history through tail" in { - decorated.tail === BinF.BranchF(Attr(1, BinF.LeafF(1)), Attr(3, BinF.LeafF(3))) - } - - "forget == head" in { - Attr.forget(decorated) === 4 - } - } - - "Coattr (free-without-suspension)" should { - - "distinguish a seed from a prebuilt layer" in { - val seed: Coattr[BinF, Int] = Coattr.Pure(7) - val layer: Coattr[BinF, Int] = Coattr.Roll(BinF.BranchF(Coattr.Pure(1), Coattr.Pure(2))) - (seed !== layer).and(seed === Coattr.Pure(7)) - } - - "nest prebuilt layers arbitrarily deep" in { - val two: Coattr[BinF, Int] = - Coattr.Roll(BinF.BranchF(Coattr.Roll(BinF.LeafF(1)), Coattr.Pure(9))) - two === Coattr.Roll(BinF.BranchF(Coattr.Roll(BinF.LeafF(1)), Coattr.Pure(9))) - } - } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala index 854bedec..15ef108a 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala @@ -1,68 +1,108 @@ package dev.constructive.eo package schemes +import scala.language.implicitConversions + import org.specs2.mutable.Specification -import optics.Optic.* +import optics.Optic.* // get, reverseGet + import schemes.samples.{Bin, BinF} -/** The hylo law as composition: `ana.cross(cata) == hylo`. +/** 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)`). Over the [[Scheme]] carrier — which keeps the `coalg`/`alg` alive + * — that compose **fuses**: * - * `ana` is a build-only `Review[Bin, Int]` and `cata` a read-only `Getter[Bin, Int]` — duals over - * `Direct`. The build-output⇄read-input seam between them is `Optic.cross` (its scaladoc names - * `ana.cross(cata)` the motivating case), yielding a forward Getter-shaped read consumed via - * `.get`. - * - `ana.cross(cata)` is the **materialising** hylo: it builds the full `S`, then folds it. - * - `Schemes.hylo(coalg, alg)` is the **fused** hylo: one pass, no intermediate `S`. - * - They agree on every seed (the hylo law) for a pure algebra; for a node-reading para algebra - * only under the seed↔`embed(coalg(seed))` correspondence. + * - 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: - // Deep examples: one-at-a-time to bound peak heap (shared test JVM). sequential - private def expand(n: Int): BinF[Int] = - if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) + // 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 sumAlg: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r + private val sumLeaves: BinF[Int] => Int = + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r - // Pure algebra, seed-typed for hylo (node argument ignored on both sides). - private val sumAlgSeed: (Int, BinF[Int]) => Int = (_, fa) => - fa match - 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) - "ana.cross(cata) composes via the build⇄read seam into a forward read" >> { - val hylo = Schemes.ana[BinF, Int, Bin](expand).cross(Schemes.cata(sumAlg)) - hylo.get(6) === 6 // six leaves of weight 1 + 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 } - "ana.cross(cata) == cata.get ∘ ana.reverseGet (the materialising composition)" >> { - val seeds = List(1, 2, 3, 5, 8, 13) - val ana = Schemes.ana[BinF, Int, Bin](expand) - val cata = Schemes.cata(sumAlg) - val composed = ana.cross(cata) - seeds.map(composed.get) === seeds.map(s => cata.get(ana.reverseGet(s))) + "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) } - "ana.cross(cata) == hylo for a pure algebra (the hylo law)" >> { - val seeds = List(1, 2, 3, 5, 8, 13) - val composed = Schemes.ana[BinF, Int, Bin](expand).cross(Schemes.cata(sumAlg)) - seeds.map(composed.get) === seeds.map(Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get) + "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 - def spine(n: Int): BinF[Int] = - if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) - def leafOrSpine(n: Int): BinF[Int] = if n < 0 then BinF.LeafF(0) else spine(n) - val depthAlg: (Int, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 0 - case BinF.BranchF(l, r) => 1 + math.max(l, r) - (Schemes.hylo[BinF, Int, Int](leafOrSpine, depthAlg).get(Deep) == Deep) must beTrue + 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/GatherScatterLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala deleted file mode 100644 index c7dee650..00000000 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/GatherScatterLawsSpec.scala +++ /dev/null @@ -1,213 +0,0 @@ -package dev.constructive.eo -package schemes - -import org.specs2.mutable.Specification - -import data.BiAffine.{Done, Step} -import schemes.samples.{Bin, BinF} -import zoo.* - -/** Decoration laws — the per-value equations of the [[Gather]]/[[Scatter]] vocabulary, plus the - * agreement of the direct `cata`/`ana` overloads with the generic decoration route at the identity - * decorations ([[Gather.cata]] / [[Scatter.ana]]) — there is no dispatch between the two: the - * direct overloads ARE the fast path, these laws pin the semantic equality. - */ -class GatherScatterLawsSpec extends Specification: - - private val tree: Bin = - Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Leaf(3)) - - private val sumAlg: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r - - // ----- demoted decorations as LAW FIXTURES --------------------------------- - // para's gather and apo's scatter have no shipped values (their generic routes - // are deliberately inferior: re-embed / distApo) — their decoration semantics - // live HERE, pinning the native Schemes.para / Schemes.apo engines. - - private def paraGather[F[_]: cats.Functor, S, A](using E: Embed[F, S]): Gather[F, (S, A), A] = - new Gather[F, (S, A), A]: - def gather(layer: F[(S, A)], a: A): (S, A) = - (E.embed(cats.Functor[F].map(layer)(_._1)), a) - - private def apoScatter[F[_]: cats.Functor, S, A](using - P: Project[F, S] - ): Scatter[F, Either[S, A], A] = - new Scatter[F, Either[S, A], A]: - def scatter(w: Either[S, A]): Either[F[Either[S, A]], A] = w match - case Right(a) => Right(a) - case Left(s) => Left(cats.Functor[F].map(P.project(s))(Left(_))) - def unit(a: A): Either[S, A] = Right(a) - - // ----- gather-side equations ---------------------------------------------- - - "Gather.histo gather == the Attr constructor" >> { - val layer: BinF[Attr[BinF, Int]] = - BinF.BranchF(Attr(1, BinF.LeafF(1)), Attr(2, BinF.LeafF(2))) - Gather - .histo[BinF, Int] - .from( - new Step[(Unit, BinF[Attr[BinF, Int]]), Int](layer, 3) - ) === Attr(3, layer) - } - - "the para gather fixture == (re-embedded subterm, result)" >> { - val layer: BinF[(Bin, Int)] = - BinF.BranchF((Bin.Leaf(1), 1), (Bin.Leaf(2), 2)) - paraGather[BinF, Bin, Int] - .from( - new Step[(Unit, BinF[(Bin, Int)]), Int](layer, 3) - ) === (Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), 3) - } - - "Gather.cata gather == keep the result, discard the layer" >> { - Gather - .cata[BinF, Int] - .from( - new Step[(Unit, BinF[Int]), Int](BinF.BranchF(1, 2), 9) - ) === 9 - } - - "Gather.cata's vestigial read side throws" >> { - val thrown = - try { val _ = Gather.cata[BinF, Int].to(()); false } - catch case _: UnsupportedOperationException => true - thrown === true - } - - // ----- scatter-side equations --------------------------------------------- - - "Scatter.ana scatters every value as a Step (no Done arm)" >> { - Scatter.ana[BinF, Int].to(7) === new Step[(BinF[Int], Unit), Int]((), 7) - } - - "Scatter.ana satisfies the unit law: to(from(Step((), a))) == Step((), a)" >> { - val d = Scatter.ana[BinF, Int] - d.to(d.from(new Step[(BinF[Int], Unit), Int]((), 5))) === - new Step[(BinF[Int], Unit), Int]((), 5) - } - - "Scatter.futu scatters Pure as Step and Roll as Done(layer)" >> { - val d = Scatter.futu[BinF, Int] - val layer: BinF[Coattr[BinF, Int]] = BinF.BranchF(Coattr.Pure(1), Coattr.Pure(2)) - (d.to(Coattr.Pure(4)) === new Step[(BinF[Coattr[BinF, Int]], Unit), Int]((), 4)) - .and(d.to(Coattr.Roll(layer)) === new Done[(BinF[Coattr[BinF, Int]], Unit), Int](layer)) - } - - "Scatter.futu's unit is Coattr.Pure" >> { - val d = Scatter.futu[BinF, Int] - d.from(new Step[(BinF[Coattr[BinF, Int]], Unit), Int]((), 4)) === Coattr.Pure(4) - } - - "the apo scatter fixture (distApo) scatters Right as Step and Left as the projected layer" >> { - val d = apoScatter[BinF, Bin, Int] - val grafted = Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)) - (d.to(Right(7)) === new Step[(BinF[Either[Bin, Int]], Unit), Int]((), 7)) - .and( - d.to(Left(grafted)) === new Done[(BinF[Either[Bin, Int]], Unit), Int]( - BinF.BranchF(Left(Bin.Leaf(1)), Left(Bin.Leaf(2))) - ) - ) - } - - "the apo scatter fixture's unit is Right" >> { - val d = apoScatter[BinF, Bin, Int] - d.from(new Step[(BinF[Either[Bin, Int]], Unit), Int]((), 7)) === Right(7) - } - - // ----- re-derivation behaviour identity ------------------------------------ - - "the generic decoration route agrees with the direct overload on cata" >> { - Schemes.cata[BinF, Bin, Int, Int](Gather.cata[BinF, Int])(sumAlg).get(tree) === - Schemes.cata[BinF, Bin, Int](sumAlg).get(tree) - } - - "the generic decoration route agrees with the direct overload on ana" >> { - def expand(n: Int): BinF[Int] = - if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) - Schemes.ana[BinF, Int, Int, Bin](Scatter.ana[BinF, Int])(expand).reverseGet(5) === - Schemes.ana[BinF, Int, Bin](expand).reverseGet(5) - } - - "histo through Gather.histo: heads-only course-of-value == cata" >> { - val viaHisto = Schemes - .cata[BinF, Bin, Attr[BinF, Int], Int](Gather.histo[BinF, Int]) { (s, layer) => - sumAlg(s, BinF.traverse.map(layer)(_.head)) - } - .get(tree) - viaHisto === Schemes.cata[BinF, Bin, Int](sumAlg).get(tree) - } - - "futu through Scatter.futu: a two-layer-per-step coalgebra builds the right tree" >> { - // Each step on seed n > 1 emits TWO layers at once: a branch whose left side is - // a prebuilt leaf layer (Roll) and whose right side keeps unfolding (Pure). - def coalg(n: Int): BinF[Coattr[BinF, Int]] = - if n <= 1 then BinF.LeafF(1) - else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) - val built = Schemes - .ana[BinF, Int, Coattr[BinF, Int], Bin](Scatter.futu[BinF, Int])(coalg) - .reverseGet(3) - built === Bin.Branch(Bin.Leaf(3), Bin.Branch(Bin.Leaf(2), Bin.Leaf(1))) - } - - // ----- native routes == generic decoration routes (the perf-win pins) -------- - - "native histo == the generic route at Gather.histo" >> { - val alg: (Bin, BinF[Attr[BinF, Int]]) => Int = (s, layer) => - sumAlg( - s, - BinF - .traverse - .map(layer)(a => - a.head + a.tail.match { - case BinF.LeafF(_) => 0; case BinF.BranchF(l, r) => l.head + r.head - } - ), - ) - Schemes.histo[BinF, Bin, Int](alg).get(tree) === - Schemes.cata[BinF, Bin, Attr[BinF, Int], Int](Gather.histo[BinF, Int])(alg).get(tree) - } - - "native futu == the generic route at Scatter.futu" >> { - def coalg(n: Int): BinF[Coattr[BinF, Int]] = - if n <= 1 then BinF.LeafF(1) - else BinF.BranchF(Coattr.Roll(BinF.LeafF(n)), Coattr.Pure(n - 1)) - Schemes.futu[BinF, Int, Bin](coalg).reverseGet(4) === - Schemes.ana[BinF, Int, Coattr[BinF, Int], Bin](Scatter.futu[BinF, Int])(coalg).reverseGet(4) - } - - // ----- generic-route end-to-end pins -------------------------------------- - - "native para == the generic route at the para gather fixture" >> { - // Algebra that uses BOTH the paired subterm and the result: - // for a branch, sum child results and add 1 for each child subterm that is a Leaf. - // tree = Branch(Branch(Leaf(1), Leaf(2)), Leaf(3)) - // Leaf(1) → 1; Leaf(2) → 2 - // Branch(Leaf(1), Leaf(2)) → 1+2 + 1 (Leaf(1)) + 1 (Leaf(2)) = 5 - // Leaf(3) → 3 - // Branch(Branch(L1,L2), Leaf(3)) → 5+3 + 0 (left is Branch) + 1 (Leaf(3)) = 9 - val alg: (Bin, BinF[(Bin, Int)]) => Int = (_, layer) => - layer match - case BinF.LeafF(n) => n - case BinF.BranchF((ls, la), (rs, ra)) => - la + ra + (if ls.isInstanceOf[Bin.Leaf] then 1 else 0) + - (if rs.isInstanceOf[Bin.Leaf] then 1 else 0) - Schemes.para[BinF, Bin, Int](alg).get(tree) === - Schemes.cata[BinF, Bin, (Bin, Int), Int](paraGather[BinF, Bin, Int])(alg).get(tree) - } - - "native apo == the generic route at the apo scatter fixture (distApo)" >> { - // Coalg with one graft: at n <= 0 emit a leaf; otherwise graft Bin.Leaf(n) as left child. - // The native apo places the graft by reference; the generic route (distApo via project) - // rebuilds it — structural equality (==) holds, reference identity (eq) only for native. - def coalg(n: Int): BinF[Either[Bin, Int]] = - if n <= 0 then BinF.LeafF(0) - else BinF.BranchF(Left(Bin.Leaf(n)), Right(n - 1)) - val nativeResult = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(2) - val genericResult = - Schemes.ana[BinF, Int, Either[Bin, Int], Bin](apoScatter[BinF, Bin, Int])(coalg).reverseGet(2) - // Both produce the same tree by value; use == not eq (generic route REBUILDS the graft). - nativeResult === genericResult - } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala deleted file mode 100644 index a79ab088..00000000 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesConcurrencySpec.scala +++ /dev/null @@ -1,112 +0,0 @@ -package dev.constructive.eo -package schemes - -import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.duration.Duration -import scala.concurrent.{Await, Future} - -import cats.Eval -import org.specs2.mutable.Specification - -import schemes.samples.{Bin, BinF} -import zoo.* - -/** Concurrency spec for the typed recursion-scheme engines. - * - * Motivation: [[Machines]] documents a thread-safety model — every machine allocates its own - * mutable state per invocation, and the only shared values ([[Machines.NoChildren]], - * [[Machines.AscendToken]]) are immutable by construction. These tests exercise that claim with N - * concurrent tasks racing on shared prebuilt fixtures. - * - * Design: 16 `Future` tasks launched in parallel, each running a MIX of schemes over shared - * prebuilt optics and a shared input tree. Every task asserts its result equals the expected - * value. No sleeps; `Await` with `Duration.Inf` gives a deterministic pass/fail. - */ -class SchemesConcurrencySpec extends Specification: - - private val N = 16 - - // Shared fixtures — prebuilt once, used from all N concurrent tasks. - private val tree: Bin = - Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Branch(Bin.Leaf(3), Bin.Leaf(4))) - - // leaf sum of `tree` = 10 - private val ExpectedSum = 10 - - private val sumAlg: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r - - // Prebuilt shared optics (shared across all tasks — the thread-safety claim under test). - private val sharedCata = Schemes.cata(sumAlg) - - private val sharedPara = Schemes.para[BinF, Bin, Int] { (_, layer) => - layer match - case BinF.LeafF(n) => n - case BinF.BranchF((_, l), (_, r)) => l + r - } - - private val sharedHisto = Schemes.histo[BinF, Bin, Int] { (_, layer) => - layer match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l.head + r.head - } - - // Per-task seed for ana/hylo/futu (varies per task to exercise different input paths). - private def expand(n: Int): BinF[Int] = - if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) - - "N=16 concurrent tasks running a mix of cata/hylo/ana/para/histo/futu/cataM on shared fixtures all return correct results" >> { - val tasks: Seq[Future[Boolean]] = (1 to N).map { taskId => - Future { - // (a) cata leaf-sum on the shared Bin tree - val cataOk = sharedCata.get(tree) == ExpectedSum - - // (b) hylo from a per-task seed — independent fold, no shared state - val seed = taskId % 5 // seeds 0–4 - val hyloResult = Schemes - .hylo[BinF, Int, Int]( - expand, - (_, fa) => - fa match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r, - ) - .get(seed) - val expectedHylo = - Schemes.cata(sumAlg).get(Schemes.ana[BinF, Int, Bin](expand).reverseGet(seed)) - val hyloOk = hyloResult == expectedHylo - - // (c) ana builds a per-task Bin from a per-task seed, cata folds it back - val built: Bin = Schemes.ana[BinF, Int, Bin](expand).reverseGet(seed) - val anaOk = Schemes.cata(sumAlg).get(built) == expectedHylo - - // (d) para on the shared tree ignoring subterms == cata - val paraOk = sharedPara.get(tree) == ExpectedSum - - // (e) histo on the shared tree heads-only == cata - val histoOk = sharedHisto.get(tree) == ExpectedSum - - // (f) futu single-layer-per-step == ana - val futuCoalg: 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)) - val futuBuilt: Bin = Schemes.futu[BinF, Int, Bin](futuCoalg).reverseGet(seed) - val futuOk = Schemes.cata(sumAlg).get(futuBuilt) == expectedHylo - - // (g) cataM[Eval].run forced per task - val cataMResult = - Schemes - .cataM[Eval, BinF, Bin, Int]((s, fa) => Eval.now(sumAlg(s, fa))) - .run(tree) - .value - val cataMOk = cataMResult == ExpectedSum - - cataOk && hyloOk && anaOk && paraOk && histoOk && futuOk && cataMOk - } - } - - val results = Await.result(Future.sequence(tasks), Duration.Inf) - results.forall(identity) must beTrue - } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala deleted file mode 100644 index 05389609..00000000 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesLawsSpec.scala +++ /dev/null @@ -1,124 +0,0 @@ -package dev.constructive.eo -package schemes - -import scala.language.implicitConversions - -import org.scalacheck.Prop.forAll -import org.scalacheck.{Arbitrary, Gen} -import org.specs2.ScalaCheck -import org.specs2.mutable.Specification - -import schemes.samples.{Bin, BinF, Rose, RoseF} - -/** Law/coherence checks for the typed pattern-functor schemes. - * - * - '''Project/Embed coherence''' — the hand-written `S`↔`F` correspondence is not - * compiler-checked, so its two round-trip laws are property-tested here. - * - '''Hylo law (pure flavor)''' — `hylo == ana.cross(cata)` holds *generically* only when the - * algebra ignores its node argument (a pure `F[A] => A` fold). Tested via `forAll`. - * - '''Hylo law (para flavor)''' — for a node-reading algebra, `hylo` threads the *seed* while - * the materializing `cata` threads the rebuilt `S`, so the two coincide only under the - * seed↔`embed(coalg(seed))` correspondence. Verified at specific points, NOT via `forAll`. - */ -class SchemesLawsSpec extends Specification with ScalaCheck: - - // bounded-depth Bin generator (keeps trees small for the property runs) - private def genBin(depth: Int): Gen[Bin] = - if depth <= 0 then Gen.choose(0, 20).map(Bin.Leaf(_)) - else - Gen.frequency( - 1 -> Gen.choose(0, 20).map(Bin.Leaf(_)), - 2 -> Gen.zip(genBin(depth - 1), genBin(depth - 1)).map((l, r) => Bin.Branch(l, r)), - ) - - private given Arbitrary[Bin] = Arbitrary(genBin(5)) - - // one layer of BinF over arbitrary Bin children - private given Arbitrary[BinF[Bin]] = Arbitrary( - Gen.oneOf( - Gen.choose(0, 20).map(BinF.LeafF(_)), - Gen.zip(genBin(4), genBin(4)).map((l, r) => BinF.BranchF(l, r)), - ) - ) - - // ----- Project/Embed coherence ----- - - "embed(project(s)) == s (round-trip through one S layer)" >> prop { (s: Bin) => - val basis = summon[Basis[BinF, Bin]] - (basis.embed(basis.project(s)) == s) must beTrue - } - - "project(embed(fs)) == fs (round-trip through one F layer)" >> prop { (fs: BinF[Bin]) => - val basis = summon[Basis[BinF, Bin]] - (basis.project(basis.embed(fs)) == fs) must beTrue - } - - // same coherence laws for the N-ary RoseF/Rose basis (a swapped label/kids mapping would escape - // the node-count behaviour tests, so the correspondence is pinned here) - private def genRose(depth: Int): Gen[Rose] = - for - label <- Gen.choose(0, 20) - kids <- - if depth <= 0 then Gen.const(List.empty[Rose]) - else Gen.choose(0, 3).flatMap(n => Gen.listOfN(n, genRose(depth - 1))) - yield Rose(label, kids) - - private given Arbitrary[Rose] = Arbitrary(genRose(3)) - - private given Arbitrary[RoseF[Rose]] = Arbitrary( - for - label <- Gen.choose(0, 20) - n <- Gen.choose(0, 3) - kids <- Gen.listOfN(n, genRose(2)) - yield RoseF(label, kids) - ) - - "embed(project(r)) == r for the N-ary RoseF/Rose basis" >> prop { (r: Rose) => - val basis = summon[Basis[RoseF, Rose]] - (basis.embed(basis.project(r)) == r) must beTrue - } - - "project(embed(fr)) == fr for the N-ary RoseF/Rose basis" >> prop { (fr: RoseF[Rose]) => - val basis = summon[Basis[RoseF, Rose]] - (basis.project(basis.embed(fr)) == fr) must beTrue - } - - // ----- hylo law, pure flavor (generic forAll) ----- - - // seed n -> a right spine of (n+1) leaves of value 1 - private val coalg: Int => BinF[Int] = n => - if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1) - - // pure algebra: ignores the node argument (so it is shape-agnostic to seed-vs-S) - private val pureSum: BinF[Int] => Int = { - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r - } - - "hylo == ana.cross(cata) for a PURE algebra (the hylo law)" >> { - forAll(Gen.choose(0, 12)) { (seed: Int) => - val fused = Schemes.hylo[BinF, Int, Int](coalg, (_, fa) => pureSum(fa)).get(seed) - val materializing = - Schemes - .ana[BinF, Int, Bin](coalg) - .cross(Schemes.cata[BinF, Bin, Int]((_, fa) => pureSum(fa))) - .get(seed) - fused == materializing - } - } - - // ----- hylo law, para flavor (point tests, not forAll) ----- - // - // A node-reading algebra: hylo sees the Int seed at each layer; the materializing path sees the - // rebuilt Bin. They are NOT equal in general — verified here only that fused matches itself and a - // hand-computed value, documenting why forAll does not apply. - - "para-flavored hylo computes the expected value at specific seeds" >> { - // alg reads neither node meaningfully here but is typed para; leaf-count over the spine - val leafCount: (Int, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 1 - case BinF.BranchF(l, r) => l + r - val h = Schemes.hylo(coalg, leafCount) - (h.get(0) == 1).and(h.get(3) == 4).and(h.get(7) == 8) - } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala deleted file mode 100644 index 11438916..00000000 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala +++ /dev/null @@ -1,176 +0,0 @@ -package dev.constructive.eo -package schemes - -import cats.data.State -import cats.{Eval, Id} -import org.specs2.mutable.Specification - -import schemes.samples.{Bin, BinF} -import zoo.* - -/** The M-generic path (`cataM` / `anaM` / `hyloM`, the tailRecM-lifted machine): - * - * - Fast-path agreement laws — `M = Id` on the lifted machine == the pure citizens on the hybrid - * machine (a real cross-architecture pin: there is NO Id special-case). - * - The materialising `anaM.andThen(cataM)` == the run-then-run composition (extensional), - * resolved to the concrete `FoldM` member (Kleisli `flatMap`); `hyloM` is the fused spelling. - * - Stack-safety on `Eval` (200k spine; safety rides on M's `tailRecM` — tested, not asserted). - * - The linear-M boundary: `List` (a branching M) is documented UNSUPPORTED — the machine's - * mutable state is shared across branches; this test pins that the result is NOT the branching - * semantics a lawful reading would give, so a contract change cannot land silently. - * - The arbo-shaped acceptance example: children fetched effectfully (a counted `GetSellOptions` - * analogue in `State`), built and folded in ONE fused pass. - */ -class SchemesMSpec extends Specification: - - // Deep (10^6 / 200k) examples: run one-at-a-time to bound peak heap (shared test JVM). - sequential - - private val tree: Bin = - Bin.Branch(Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)), Bin.Leaf(3)) - - private val sumAlg: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r - - private def expand(n: Int): BinF[Int] = - if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) - - private val sumAlgSeed: (Int, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r - - // ----- fast-path agreement (M = Id vs the pure hybrid machine) ------------- - - "cataM[Id].run == cata.get (cross-architecture agreement)" >> { - Schemes.cataM[Id, BinF, Bin, Int]((s, fa) => sumAlg(s, fa)).run(tree) === - Schemes.cata(sumAlg).get(tree) - } - - "anaM[Id].run == ana.reverseGet" >> { - Schemes.anaM[Id, BinF, Int, Bin](n => expand(n)).run(6) === - Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) - } - - "hyloM[Id].run == hylo.get" >> { - Schemes.hyloM[Id, BinF, Int, Int](n => expand(n), (s, fa) => sumAlgSeed(s, fa)).run(13) === - Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get(13) - } - - // ----- fusion --------------------------------------------------------------- - - "anaM.andThen(cataM) resolves to the concrete materialising member and == run∘run" >> { - val anaM = Schemes.anaM[Id, BinF, Int, Bin](n => expand(n)) - val cataM = Schemes.cataM[Id, BinF, Bin, Int]((s, fa) => sumAlg(s, fa)) - val fused: FoldM[Id, Int, Int] = anaM.andThen(cataM) // concrete FoldM.andThen (Kleisli) - List(1, 2, 3, 5, 8, 13).map(fused.run) === - List(1, 2, 3, 5, 8, 13).map(seed => cataM.run(anaM.run(seed))) - } - - // ----- stack-safety on Eval -------------------------------------------------- - - // Depth bar: stack-safety needs >> the ~10k-frame JVM stack; 200k proves the - // tailRecM-driven machine (the 10^6 SPACE bar lives with the pure-machine sweeps — - // Eval's tailRecM adds per-event Either+Eval nodes, and the suites share one JVM). - "hyloM[Eval] is stack-safe on a 200k-deep spine (tailRecM-driven, no intermediate Bin)" >> { - val Deep = 200_000 - def spine(n: Int): BinF[Int] = - if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) - def leafOrSpine(n: Int): BinF[Int] = if n < 0 then BinF.LeafF(0) else spine(n) - val depthAlg: (Int, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 0 - case BinF.BranchF(l, r) => 1 + math.max(l, r) - val run = Schemes - .hyloM[Eval, BinF, Int, Int]( - n => Eval.now(leafOrSpine(n)), - (s, fa) => Eval.now(depthAlg(s, fa)), - ) - .run(Deep) - (run.value == Deep) must beTrue - } - - // ----- the linear-M boundary -------------------------------------------------- - - "List (a branching M) is UNSUPPORTED — the machine does not implement branching semantics" >> { - // One fetch returns TWO alternative layers. A lawful branching interpretation - // would yield two independent folds: List(1, 2). The machine's mutable state is - // shared across List's branches, so it must NOT produce that — this pin fails - // loudly if the contract ever changes (e.g. a persistent-state machine lands). - def coalgM(n: Int): List[BinF[Int]] = - if n == 9 then List(BinF.LeafF(1), BinF.LeafF(2)) else List(BinF.LeafF(n)) - val results = Schemes - .hyloM[List, BinF, Int, Int](coalgM, (_, fa) => List(sumAlgSeed(0, fa))) - .run(9) - (results != List(1, 2)) must beTrue - } - - // ----- propagation of short-circuiting effects -------------------------------- - - "cataM[Option] propagates a mid-fold None" >> { - // Algebra returns None for the inner branch (the Branch(Leaf(1), Leaf(2)) node) only. - val algM: (Bin, BinF[Int]) => Option[Int] = (s, fa) => - s match - case Bin.Branch(Bin.Leaf(1), Bin.Leaf(2)) => None // force failure at this node - case _ => Some(sumAlg(s, fa)) - Schemes.cataM[Option, BinF, Bin, Int](algM).run(tree) === None - } - - "anaM[Option] propagates a mid-unfold None" >> { - // Coalg returns None at seed 3 — forces failure mid-build. - val coalgM: Int => Option[BinF[Int]] = n => if n == 3 then None else Some(expand(n)) - Schemes.anaM[Option, BinF, Int, Bin](coalgM).run(6) === None - } - - // ----- re-forcing the same Eval result is safe (Fix 1 regression guard) ------ - - "re-forcing the same Eval result is safe (fresh state per force)" >> { - val m = Schemes - .hyloM[Eval, BinF, Int, Int]( - n => Eval.now(expand(n)), - (s, fa) => Eval.now(sumAlgSeed(s, fa)), - ) - .run(6) - val expected = Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get(6) - (m.value === expected).and(m.value === expected) - } - - "exception-interrupted force then re-force computes correctly (fresh state per force)" >> { - // On the first invocation of the 3-node specific interior seed, throw via a one-shot flag. - var thrown = false - val algM: (Int, BinF[Int]) => Eval[Int] = (seed, fa) => - if seed == 3 && !thrown then - thrown = true - Eval.later(throw new RuntimeException("deliberate first-force failure")) - else Eval.now(sumAlgSeed(seed, fa)) - val m = Schemes - .hyloM[Eval, BinF, Int, Int](n => Eval.now(expand(n)), algM) - .run(6) - // First force: throws - val firstThrew = - try { val _ = m.value; false } - catch - case _: RuntimeException => true - // Second force: flag already set, should succeed with correct answer - val expected = Schemes.hylo[BinF, Int, Int](expand, sumAlgSeed).get(6) - firstThrew.and(m.value === expected) - } - - // ----- the arbo-shaped acceptance example ------------------------------------- - - "arbo-shaped: effectful children (counted fetches), built and folded in one fused pass" >> { - // GetSellOptions analogue: fetching a node's options is effectful — here State - // counts the service calls, the way arbo's M wraps an options service. - type Svc[T] = State[Int, T] - def fetchOptions(n: Int): Svc[BinF[Int]] = State(calls => (calls + 1, expand(n))) - - val build = Schemes.anaM[Svc, BinF, Int, Bin](fetchOptions) - val best = Schemes.cataM[Svc, BinF, Bin, Int]((s, fa) => State.pure(sumAlg(s, fa))) - val selection: FoldM[Svc, Int, Int] = build.andThen(best) - - val (calls, result) = selection.run(6).run(0).value - // expand(6) yields 11 nodes (6 leaves of weight 1) — one fetch per node, one pass. - (result === 6).and(calls === 11) - } 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 f3a79c8f..e261f9bd 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -3,23 +3,23 @@ package schemes import scala.language.implicitConversions -import cats.instances.int.given import org.specs2.mutable.Specification import data.Forget -import data.Forget.given -import optics.{Getter, Lens, Optic} -import optics.Optic.* // get, andThen, cross, reverseGet, foldMap +import optics.{Getter, Optic} +import optics.Optic.* // get, readOnly, reverseGet, foldMap, andThen import schemes.samples.{Bin, BinF, Rose, RoseF} -/** Behaviour spec for the typed pattern-functor schemes (`cata` / `ana` / `hylo`) and `fLayer`. - * Companion law/coherence checks live in `SchemesLawsSpec`. +/** Behaviour spec for the node-blind recursion-scheme spine (`cata` / `ana` / `hylo`) and `fLayer`. * - * Type-safety note (R3): every `gather`/`alg`/`coalg` below pattern-matches `BinF`'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`. + * 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: @@ -30,59 +30,34 @@ class SchemesSpec extends Specification: private val tree: Bin = Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) - // pure leaf-sum gather (ignores the node S) - private val sumLeaves: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r + // 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 + + private val depthAlg: BinF[Int] => Int = + case BinF.LeafF(_) => 0 + case BinF.BranchF(l, r) => 1 + math.max(l, r) - // ----- cata (typed fold) ----- + // ----- cata (typed node-blind fold) ----- "cata folds a Bin to a value through F's named constructors" >> { - val sumG: Getter[Bin, Int] = Schemes.cata(sumLeaves) - (sumG.get(tree) == 6) must beTrue + (Schemes.cata[BinF, Bin, Int](sumLeaves).get(tree) == 6) must beTrue } - "cata is paramorphism-flavored: gather can read the original node S" >> { - // count Branch nodes by reading the node argument, not the folded children - val branchCount: (Bin, BinF[Int]) => Int = (node, fa) => - val here = node match - case Bin.Branch(_, _) => 1 - case Bin.Leaf(_) => 0 - val below = fa match - case BinF.LeafF(_) => 0 - case BinF.BranchF(l, r) => l + r - here + below - (Schemes.cata(branchCount).get(tree) == 2) must beTrue + "cata handles a single leaf (no recursive positions)" >> { + (Schemes.cata[BinF, Bin, Int](sumLeaves).get(Bin.Leaf(7)) == 7) must beTrue } - "cata-as-Getter composes onto an outer Getter via andThen" >> { - val composed: Getter[(String, Bin), Int] = - Getter[(String, Bin), Bin](_._2).andThen(Schemes.cata(sumLeaves)) - (composed.get(("x", tree)) == 6) 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 } - "a composed Lens focuses a recursive field; the scheme folds it; the lens still writes" >> { - final case class Inner(label: String, tree: Bin) - final case class Doc(id: Int, inner: Inner) - - 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 doc = Doc(1, Inner("x", tree)) // tree = leaf sum 6 - // read: focus the recursive field with the composed lens, fold it with the scheme. - // (Schemes are read-only Getters, so we either read at the leaf — `cata.get(lens.get(doc))` — - // or wrap the lens read in a Getter to build a reusable composed Getter[Doc, Int].) - val docLeafSum: Getter[Doc, Int] = - Getter[Doc, Bin](deepTree.get).andThen(Schemes.cata(sumLeaves)) - - val pruned = deepTree.replace(Bin.Leaf(0))(doc) // write through the same composed lens - - (Schemes.cata(sumLeaves).get(deepTree.get(doc)) == 6) - .and(docLeafSum.get(doc) == 6) - .and(deepTree.get(pruned) == Bin.Leaf(0)) - .and(pruned.inner.label == "x") // the rest of the record is untouched + "cata-as-read composes onto an outer Getter via andThen (.readOnly bridges the carrier)" >> { + val composed: Getter[(String, Bin), Int] = + Getter[(String, Bin), Bin](_._2).andThen(Schemes.cata[BinF, Bin, Int](sumLeaves).readOnly) + (composed.get(("x", tree)) == 6) must beTrue } // ----- ana (typed build) ----- @@ -91,68 +66,30 @@ class SchemesSpec extends Specification: // 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 -> Branch(Branch(Branch(Leaf 1, Leaf 1), Leaf 1), Leaf 1): 4 leaves of 1, depth 3 - (Schemes.cata(sumLeaves).get(built) == 4).and( - Schemes - .cata[BinF, Bin, Int]((_, fa) => - fa match - case BinF.LeafF(_) => 0 - case BinF.BranchF(l, _) => 1 + l - ) - .get(built) == 3 - ) - } - - "ana.cross(cata) is the materializing hylo (builds the Bin, then folds)" >> { - val spine: Int => BinF[Int] = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(n - 1, -1) - val refold = Schemes.ana[BinF, Int, Bin](spine).cross(Schemes.cata(sumLeaves)) - (refold.get(3) == 4) must beTrue + // 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) } // ----- 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) - val leafCount: (Int, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 1 - case BinF.BranchF(l, r) => l + r // seed 3 -> a right spine of 4 leaves - (Schemes.hylo(coalg, leafCount).get(3) == 4) must beTrue + (Schemes.hylo[BinF, Int, Int](coalg, sumLeaves).get(3) == 4) must beTrue } "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 leafCount: (Int, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 1 - case BinF.BranchF(l, r) => l + r val toStr: Getter[Int, String] = - Schemes.hylo(coalg, leafCount).andThen(Getter[Int, String](_.toString)) - (toStr.get(3) == "4") must beTrue - } - - // ----- edge cases ----- - - "cata / hylo handle a single leaf (no recursive positions)" >> { - (Schemes.cata(sumLeaves).get(Bin.Leaf(7)) == 7).and( Schemes - .hylo[BinF, Int, Int]( - n => BinF.LeafF(n), - (_, fa) => - fa match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r, - ) - .get(42) == 42 - ) - } - - "cata handles a one-level Branch(Leaf, Leaf)" >> { - (Schemes.cata(sumLeaves).get(Bin.Branch(Bin.Leaf(4), Bin.Leaf(5))) == 9) must beTrue + .hylo[BinF, Int, Int](coalg, sumLeaves) + .readOnly + .andThen(Getter[Int, String](_.toString)) + (toStr.get(3) == "4") must beTrue } - // ----- fLayer: the single-layer Forget[F] optic (R1) ----- + // ----- fLayer: the single-layer Forget[F] optic ----- "fLayer is a usable Optic[S,S,S,S,Forget[F]]: to/from round-trip one layer" >> { val layer: Optic[Bin, Bin, Bin, Bin, Forget[BinF]] = Schemes.fLayer[BinF, Bin] @@ -162,18 +99,15 @@ class SchemesSpec extends Specification: "fLayer reads its layer's immediate foci via foldMap (Foldable[BinF])" >> { val layer = Schemes.fLayer[BinF, Bin] - // two immediate children of a Branch (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 } - // ----- stack-safety (R2): 10^6 deep ----- + // ----- stack-safety: 10^6 deep ----- // - // cata and hylo descend a 10^6-deep spine; ana additionally materializes an O(n) Bin. The - // foldLayered machine (the same < 512-on-stack / heap-ArrayDeque hybrid as the PSVec schemes) - // moves the deep recursion off the JVM call stack onto the heap, so these complete without - // StackOverflowError where a naive recursion would overflow — in O(depth) space, no Eval chain, - // so they run in the default test heap (no fork needed, like #23's PSVec 10^6 cases). + // 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 @@ -183,57 +117,39 @@ class SchemesSpec extends Specification: while i < Deep do b = Bin.Branch(b, Bin.Leaf(0)) i += 1 - val depth: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 0 - case BinF.BranchF(l, r) => 1 + math.max(l, r) - (Schemes.cata(depth).get(b) == Deep) must beTrue + (Schemes.cata[BinF, Bin, Int](depthAlg).get(b) == Deep) 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) - val depth: (Int, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 0 - case BinF.BranchF(l, r) => 1 + math.max(l, r) - (Schemes.hylo(coalg, depth).get(Deep) == Deep) must beTrue + (Schemes.hylo[BinF, Int, Int](coalg, depthAlg).get(Deep) == Deep) 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) - val depth: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 0 - case BinF.BranchF(l, r) => 1 + math.max(l, r) - (Schemes.cata(depth).get(deep) == Deep) must beTrue + (Schemes.cata[BinF, Bin, Int](depthAlg).get(deep) == Deep) must beTrue } - // ----- wide-and-deep: exercise the Traverse[F] sequencing path a binary spine misses ----- + // ----- wide-and-deep: exercise the Traverse[F] sequencing a binary spine misses ----- - "cata/hylo stay safe on a wide-AND-deep RoseF (high fanout + deep)" >> { + "hylo stays safe on a wide-AND-deep RoseF (high fanout + deep)" >> { val DeepRose = 100_000 val Width = 8 - // each level: one deep child (d-1) + Width leaf children (-1 -> empty RoseF) - val coalg: Int => RoseF[Int] = d => - if d <= 0 then RoseF(0, Nil) - else RoseF(d, (d - 1) :: List.fill(Width)(-1)) - val countNodes: (Int, RoseF[Int]) => Int = (_, fr) => 1 + fr.kids.sum - // nodes = (DeepRose+1) spine nodes + DeepRose*Width leaves + 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(coalg, countNodes).get(DeepRose) == expected) must beTrue + (Schemes.hylo[RoseF, Int, Int](coalg, countNodes).get(DeepRose) == expected) must beTrue } - // ana + cata over the N-ary RoseF (the hylo case above never builds/folds a real Rose, so - // this is the only test exercising the Traverse[RoseF]+Eval sequencing for those two schemes). "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: (Rose, RoseF[Int]) => Int = (_, fr) => 1 + fr.kids.sum + 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(countNodes).get(built) == expected) must beTrue + (Schemes.cata[RoseF, Rose, Int](countNodes).get(built) == expected) must beTrue } diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala deleted file mode 100644 index e91edec4..00000000 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesZooSpec.scala +++ /dev/null @@ -1,196 +0,0 @@ -package dev.constructive.eo -package schemes - -import org.specs2.mutable.Specification - -import schemes.samples.{Bin, BinF} -import zoo.* - -/** Behaviour + law spec for the named zoo (`para` / `apo` / `histo` / `futu`): - * - * - Degeneration laws — each member collapses to its plain dual when its decoration is unused. - * - The graft law — `apo`'s `Left` subtree lands in the result **by reference** (`eq`): the - * law-shaped form of the O(1)-graft claim, immune to bench-box noise. - * - Stack-safety to 10⁶ per member (tested, not asserted). - */ -class SchemesZooSpec extends Specification: - - // Deep (10^6 / 200k) examples: run one-at-a-time to bound peak heap (shared test JVM). - 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 sumAlg: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r - - // ----- degeneration laws --------------------------------------------------- - - "para ignoring subterms == cata" >> { - val viaPara = Schemes - .para[BinF, Bin, Int] { (s, layer) => - sumAlg(s, BinF.traverse.map(layer)(_._2)) // drop the paired subterms - } - .get(tree) - viaPara === Schemes.cata(sumAlg).get(tree) - } - - "para sees the original subterm at every child slot" >> { - // Algebra returns (sum, ok): sum is the cata sum of the subtree; ok checks the PAIRED - // subterm re-folds to the PAIRED result — a non-tautological cross-check. - val coherent = Schemes - .para[BinF, Bin, (Int, Boolean)] { (_, layer) => - layer match - case BinF.LeafF(n) => (n, true) - case BinF.BranchF((ls, (lSum, lOk)), (rs, (rSum, rOk))) => - ( - lSum + rSum, - lOk && rOk && - Schemes.cata(sumAlg).get(ls) == lSum && - Schemes.cata(sumAlg).get(rs) == rSum, - ) - } - .get(tree) - // tree = Branch(Branch(Leaf(1), Leaf(2)), Branch(Leaf(3), Leaf(4))); leaf sum = 1+2+3+4 = 10 - coherent === (10, true) - } - - "never-grafting apo == ana" >> { - def expand(n: Int): BinF[Int] = - if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) - val viaApo = Schemes - .apo[BinF, Int, Bin](n => BinF.traverse.map(expand(n))(Right(_))) - .reverseGet(6) - viaApo === Schemes.ana[BinF, Int, Bin](expand).reverseGet(6) - } - - "heads-only histo == cata" >> { - val viaHisto = Schemes - .histo[BinF, Bin, Int] { (s, layer) => - sumAlg(s, BinF.traverse.map(layer)(_.head)) - } - .get(tree) - viaHisto === Schemes.cata(sumAlg).get(tree) - } - - "single-layer futu == ana" >> { - def expand(n: Int): BinF[Int] = - 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) - } - - // ----- the graft law (O(1), by reference) ---------------------------------- - - "apo grafts a finished subtree BY REFERENCE (eq), never rebuilt" >> { - val grafted: Bin = Bin.Branch(Bin.Leaf(98), Bin.Leaf(99)) - // Unfold downward; at seed 1 graft the finished subtree as the left child. - def coalg(n: Int): BinF[Either[Bin, Int]] = - if n <= 0 then BinF.LeafF(7) - else if n == 1 then BinF.BranchF(Left(grafted), Right(0)) - else BinF.BranchF(Right(n - 1), Right(n - 1)) - 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 grafts by reference on the HEAP path too (depth > OnStackLimit=512)" >> { - val grafted: Bin = Bin.Branch(Bin.Leaf(77), Bin.Leaf(88)) - // Descend a spine to depth 600 (past the on-stack limit of 512), then graft once. - val GraftDepth = 600 - def coalg(n: Int): BinF[Either[Bin, Int]] = - if n <= 0 then BinF.LeafF(0) - else if n == 1 then BinF.BranchF(Left(grafted), Right(0)) - else BinF.BranchF(Right(n - 1), Left(Bin.Leaf(0))) - val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(GraftDepth) - // Navigate left spine to find the graft slot (iterative — safe at any depth) - var cursor: Bin = built - var steps = GraftDepth - 1 - while steps > 0 do - cursor = cursor match - case Bin.Branch(l, _) => l - case leaf => leaf - steps -= 1 - val graftSlot = cursor match - case Bin.Branch(left, _) => left - case other => other - (graftSlot.asInstanceOf[AnyRef] eq grafted.asInstanceOf[AnyRef]) === true - } - - "histo uses real history: leaf-depth-weighted sum needs grandchildren" >> { - // An algebra unreachable by plain cata in one pass: each branch adds its - // grandchildren's results twice (course-of-value: reads two levels down). - val cov = Schemes - .histo[BinF, Bin, Int] { (_, layer) => - layer match - 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 (leaf children have no grandchildren); - // root: heads 3+7 plus grandchildren-through-history (1+2) + (3+4) = 20. - cov === 20 - } - - // ----- stack-safety: 10^6 per member (tested, not asserted) ---------------- - - private val Deep = 1_000_000 - - private def deepSpine(): Bin = - var b: Bin = Bin.Leaf(0) - var i = 0 - while i < Deep do - b = Bin.Branch(b, Bin.Leaf(0)) - i += 1 - b - - "para is stack/space-safe folding a 10^6-deep Bin spine" >> { - val depth: (Bin, BinF[(Bin, Int)]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 0 - case BinF.BranchF((_, l), (_, r)) => 1 + math.max(l, r) - (Schemes.para(depth).get(deepSpine()) == Deep) must beTrue - } - - "apo is stack/space-safe building a 10^6-deep Bin" >> { - def coalg(n: Int): BinF[Either[Bin, Int]] = - if n <= 0 then BinF.LeafF(0) else BinF.BranchF(Right(n - 1), Left(Bin.Leaf(0))) - val built = Schemes.apo[BinF, Int, Bin](coalg).reverseGet(Deep) - val depth: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 0 - case BinF.BranchF(l, r) => 1 + math.max(l, r) - (Schemes.cata(depth).get(built) == Deep) must beTrue - } - - "histo is stack/space-safe folding a 10^6-deep Bin spine (O(n) Attr cells)" >> { - val depth: (Bin, BinF[Attr[BinF, Int]]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 0 - case BinF.BranchF(l, r) => 1 + math.max(l.head, r.head) - (Schemes.histo(depth).get(deepSpine()) == Deep) must beTrue - } - - "futu is stack/space-safe building a 10^6-deep Bin (deep Coattr chains included)" >> { - // Every other step emits a prebuilt two-layer segment (Roll over Roll). - def coalg(n: Int): BinF[Coattr[BinF, Int]] = - if n <= 0 then BinF.LeafF(0) - else if n % 2 == 0 then BinF.BranchF(Coattr.Roll(BinF.LeafF(0)), Coattr.Pure(n - 1)) - else BinF.BranchF(Coattr.Pure(n - 1), Coattr.Roll(BinF.LeafF(0))) - val built = Schemes.futu[BinF, Int, Bin](coalg).reverseGet(Deep) - val size: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 1 - case BinF.BranchF(l, r) => 1 + l + r - (Schemes.cata(size).get(built) > Deep) must beTrue - } 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..51504980 --- /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/proto/ProtoFusionSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/proto/ProtoFusionSpec.scala deleted file mode 100644 index eace6399..00000000 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/proto/ProtoFusionSpec.scala +++ /dev/null @@ -1,58 +0,0 @@ -package dev.constructive.eo -package schemes -package proto - -import org.specs2.mutable.Specification - -import optics.Optic.* -import schemes.samples.{Bin, BinF} -import Scheme.given - -/** Correctness pin for the fusion prototype: the fused `ana.cross(cata)` must agree with the - * materialising spellings (the hylo law for a node-blind algebra), and stay stack-safe. - */ -class ProtoFusionSpec extends Specification: - - sequential - - private val coalg: Int => BinF[Int] = n => - if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) - - // node-BLIND fold (fusable) - private val pureSum: BinF[Int] => Int = { - case BinF.LeafF(v) => v - case BinF.BranchF(l, r) => l + r - } - - // node-READING fold (para; equals pureSum here but typed to need the node) - private val nodeSum: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(v) => v - case BinF.BranchF(l, r) => l + r - - private val cata = Proto.cata[BinF, Bin, Int](pureSum) - private val para = Proto.para[BinF, Bin, Int](nodeSum) - private val ana = Proto.ana[BinF, Int, Bin](coalg) - - "fused ana.cross(cata) == manual cata.get(ana.reverseGet) == para.cross materialised" >> { - val seeds = List(1, 2, 3, 5, 8, 13, 21) - val fused = ana.cross(cata) // X = Nothing → fused - val viaPara = ana.cross(para) // X = S → materialising - val fusedR = seeds.map(fused.get) - val manualR = seeds.map(s => cata.get(ana.reverseGet(s))) - val paraR = seeds.map(viaPara.get) - (fusedR === manualR).and(fusedR === paraR) - } - - "the fused cross is stack-safe at depth 10^6 (no intermediate Bin to overflow)" >> { - val Deep = 1_000_000 - def spine(n: Int): BinF[Int] = - if n <= 0 then BinF.LeafF(0) else BinF.BranchF(n - 1, -1) - def leafOrSpine(n: Int): BinF[Int] = if n < 0 then BinF.LeafF(0) else spine(n) - val depthAlg: BinF[Int] => Int = { - case BinF.LeafF(_) => 0 - case BinF.BranchF(l, r) => 1 + math.max(l, r) - } - val fused = Proto.ana[BinF, Int, Bin](leafOrSpine).cross(Proto.cata[BinF, Bin, Int](depthAlg)) - (fused.get(Deep) == Deep) must beTrue - } 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) From 93c825bda960d20b44a09506ad94e9c70fd5933c Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 13:29:29 +0200 Subject: [PATCH 35/61] =?UTF-8?q?feat(schemes):=20metamorphism=20(meta)=20?= =?UTF-8?q?+=20metaChrono=20=E2=80=94=20the=20non-fusing=20fold=E2=86=92un?= =?UTF-8?q?fold=20dual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the 2×2: refold vs metamorphism × trivial vs universal index. - meta = cata.meta(ana) / Schemes.meta: fold an F-recursive S to a neck value A, then unfold a DIFFERENT G-recursive T. The direction-dual of hylo. - metaChrono = histo.meta(futu) / Schemes.metaChrono: the same at the universal indices (cofree fold → free unfold). Degenerates to meta. - New Meta[S, A, T] citizen, X = A (the neck). The non-trivial existential is the point: meta CANNOT fuse — fold over F and unfold over G ≠ F leave no project∘embed to cancel, so the neck is genuinely materialised. Honest mirror of Hylo's X = Nothing. The compile-time tell: meta keeps BOTH Bases where hylo drops them. - meta hosts the seam on the fold (Cata/Histo, fold-first), dual to cross hosting on the unfold (Ana/Futu); distinct name, no andThen collision. MetaSpec (6) uses a genuinely heterogeneous Bin --leafSum--> Int --spine--> Rose (F = BinF, G = RoseF) and witnesses no-fusion via split counting bases: the F-fold's project AND the G-build's embed both fire (contrast hylo: neither). 35/35 green, formatted. Co-Authored-By: Claude Opus 4.8 --- .../dev/constructive/eo/schemes/Scheme.scala | 30 ++-- .../dev/constructive/eo/schemes/Schemes.scala | 61 +++++++- .../dev/constructive/eo/schemes/zoo/Ana.scala | 2 +- .../constructive/eo/schemes/zoo/Cata.scala | 22 ++- .../constructive/eo/schemes/zoo/Futu.scala | 9 +- .../constructive/eo/schemes/zoo/Histo.scala | 26 +++- .../constructive/eo/schemes/zoo/Meta.scala | 25 ++++ .../constructive/eo/schemes/ChronoSpec.scala | 2 + .../constructive/eo/schemes/MetaSpec.scala | 136 ++++++++++++++++++ .../dev/constructive/eo/schemes/ZooSpec.scala | 7 +- 10 files changed, 286 insertions(+), 34 deletions(-) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala index b505e599..277730fd 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala @@ -3,18 +3,19 @@ package schemes import accessor.{Accessor, ReverseAccessor} -/** The recursion-scheme carrier. At the value level `Scheme[X, A] = A` (the focus); `X` is a phantom - * at runtime but a *load-bearing type-level index* at the optic level — it is the thesis of this - * module made into a type. +/** The recursion-scheme carrier. At the value level `Scheme[X, A] = A` (the focus); `X` is a + * phantom at runtime but a *load-bearing type-level index* at the optic level — it is the thesis + * of this module made into a type. * * ==Why a distinct carrier (not `Direct`)== * - * The plain `Direct`-backed [[dev.constructive.eo.optics.Getter]] / [[dev.constructive.eo.optics.Review]] - * collapse a scheme to an opaque closure (`S => A` / `Seed => S`), throwing the algebra away. Once - * the `coalg`/`alg` are gone the `project ∘ embed = id` cancellation that deforestation rides on is - * *invisible*, so `ana.cross(cata)` over `Direct` can only materialise the whole `S` and then fold - * it. The scheme citizens ([[Cata]], [[Ana]]) instead **carry their (co)algebra**, so the fused - * [[Ana.cross]] can rebuild the one-pass machine — `hylo` with no intermediate `S`. + * The plain `Direct`-backed [[dev.constructive.eo.optics.Getter]] / + * [[dev.constructive.eo.optics.Review]] collapse a scheme to an opaque closure (`S => A` / + * `Seed => S`), throwing the algebra away. Once the `coalg`/`alg` are gone the + * `project ∘ embed = id` cancellation that deforestation rides on is *invisible*, so + * `ana.cross(cata)` over `Direct` can only materialise the whole `S` and then fold it. The scheme + * citizens ([[Cata]], [[Ana]]) instead **carry their (co)algebra**, so the fused [[Ana.cross]] can + * rebuild the one-pass machine — `hylo` with no intermediate `S`. * * ==The existential `X` is the index== * @@ -23,13 +24,16 @@ import accessor.{Accessor, ReverseAccessor} * - [[Cata]] : `X = Nothing` — a node-blind fold (`alg: F[A] => A`). It retains nothing of the * source tree, so `ana.cross(cata)` is sound to **fuse** (deforest). * - [[Ana]] : `X = S` — the unfold threads the built structure. + * - [[Histo]] : `X = Attr[F, A]` (cofree comonad), [[Futu]] : `X = Coattr[F, A]` (free monad) — + * the universal indices, refining the fold/unfold towers. + * - [[Meta]] : `X = A` — the metamorphism's neck; non-trivial precisely because a fold→unfold + * over *different* functors cannot deforest, the honest mirror of [[Hylo]]'s `X = Nothing`. * - * Refining `X` upward (e.g. `F[(S, A)]` for a paramorphism, `Attr[F, A]` for a histomorphism) trades - * allocation for capability; the `(co)free (co)monads` are the universal such indices. Those are the - * *next* schemes — this file is the node-blind spine (cata / ana / hylo). + * Refining `X` upward trades allocation for capability; the `(co)free (co)monads` are the + * universal such indices. * * Value-identical to `Direct`; kept separate so the scheme optics are honest citizens and so the - * fused [[Ana.cross]] / [[Ana.andThen]] members are reachable in overload resolution. + * fused [[Ana.cross]] / [[Futu.cross]] members are reachable in overload resolution. */ opaque type Scheme[X, A] = A 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 c3488c23..be580b7e 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -5,7 +5,7 @@ import cats.Traverse import data.{Forget, ForgetK} import optics.Optic -import zoo.{Ana, Attr, Cata, Coattr, Futu, Histo, Hylo} +import zoo.{Ana, Attr, Cata, Coattr, Futu, Histo, Hylo, Meta} /** 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`. @@ -24,7 +24,7 @@ import zoo.{Ana, Attr, Cata, Coattr, Futu, Histo, Hylo} * * `histo` refines `cata`'s index up the comonad tower; `futu` refines `ana`'s up the monad tower. * - * ==hylo is the fusion, not a primitive== + * ==hylo is the fusion, not a primitive — and meta is the honest non-fusion== * * [[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** over @@ -32,12 +32,19 @@ import zoo.{Ana, Attr, Cata, Coattr, Futu, Histo, Hylo} * intermediate `S`*. The [[FusionSpec]] pins the hylo law and witnesses the deforestation (the * fused refold never calls `project`/`embed`). * + * 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 + * genuinely 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 [[hylo]] / [[meta]] / + * [[chrono]] / [[metaChrono]]. + * * 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. * * The citizen classes live in [[zoo]] ([[zoo.Cata]] / [[zoo.Ana]] / [[zoo.Hylo]] / [[zoo.Histo]] / - * [[zoo.Futu]]); this object is the user-facing constructor surface plus [[fLayer]]. + * [[zoo.Futu]] / [[zoo.Meta]]); this object is the user-facing constructor surface plus + * [[fLayer]]. */ object Schemes: @@ -127,3 +134,51 @@ object Schemes: (_, layer) => Attr(algebra(layer), layer), ) new Hylo[A, B](a => Attr.forget(build(Coattr.Pure(a)))) + + /** Metamorphism — the **fold-then-unfold** read `S => T`, the direction-dual of [[hylo]]. Fold + * the `F`-recursive `S` to a neck value `A` (node-blind `alg`), then unfold `A` into a fresh + * `G`-recursive `T` (`coalg`). Definitionally `cata(alg).meta(ana(coalg))`; this builds the same + * two-pass machine directly. + * + * **Does not fuse** — unlike [[hylo]] it keeps *both* `Basis`es (`Project[F, S]` to fold, + * `Embed[G, T]` to build), because the fold's `F` and the unfold's `G` differ: there is no + * shared functor whose `project ∘ embed` could cancel, so the neck `A` is genuinely materialised + * (the [[zoo.Meta]]'s existential `X = A`). The compile-time tell is right here in the + * signature: where `hylo` needs only `Traverse`, `meta` cannot drop either `Basis`. Stack-safe + * (two [[Machines.foldLayered]] passes). + */ + def meta[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.foldLayered[G, A, T](coalg, (_, gr) => E.embed(gr)) + new Meta[S, A, T](fold.andThen(unfold)) + + /** Metamorphism at the universal indices — the **fold-then-unfold dual of [[chrono]]**. Fold the + * `F`-recursive `S` course-of-value to a neck `A` (the cofree history, [[zoo.Attr]]), then + * multi-layer-unfold `A` into a `G`-recursive `T` (the free coalgebra, [[zoo.Coattr]]). + * Definitionally `histo(algebra).meta(futu(coalg))`. + * + * **Does not fuse**, for the same reason as [[meta]]: `F ≠ G`, so the neck `A` is materialised + * (`X = A`). 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. + * Stack-safe. + */ + def metaChrono[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]): Meta[S, A, T] = + val fold: S => A = + val toAttr = Machines.foldLayered[F, S, Attr[F, A]]( + P.project, + (_, layer) => Attr(algebra(layer), layer), + ) + s => Attr.forget(toAttr(s)) + val expand: Coattr[G, A] => G[Coattr[G, A]] = + case Coattr.Pure(a) => coalg(a) + case Coattr.Roll(layer) => layer + val unfold: A => T = + val run = Machines.foldLayered[G, Coattr[G, A], T](expand, (_, gr) => E.embed(gr)) + a => run(Coattr.Pure(a)) + new Meta[S, A, T](fold.andThen(unfold)) 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 index c98e1d87..535bc2a2 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala @@ -18,7 +18,7 @@ final class Ana[F[_], Seed, S](private[zoo] val coalg: Seed => F[Seed])(using ) extends Optic[Unit, S, Unit, Seed, Scheme]: type X = S - private val build: Seed => S = + private[zoo] val build: Seed => S = Machines.foldLayered[F, Seed, S](coalg, (_, fr) => E.embed(fr)) def to(u: Unit): Scheme[X, Unit] = Scheme(()) 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 index 34151575..df50ff4c 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala @@ -6,10 +6,10 @@ import cats.Traverse import optics.Optic -/** Catamorphism citizen — a **node-blind** fold worn as an optic over [[Scheme]] with `X = Nothing`, - * the forgetful (trivial) resolution of the recursion index. `Getter`-shaped (`Optic[S, Unit, A, - * Unit, Scheme]`): the read `to` runs the fold; the build side is vestigial. Carries `alg` so - * [[Ana.cross]] can rebuild the fused [[Hylo]] machine. +/** Catamorphism citizen — a **node-blind** fold worn as an optic over [[Scheme]] with + * `X = Nothing`, the forgetful (trivial) resolution of the recursion index. `Getter`-shaped + * (`Optic[S, Unit, A, Unit, Scheme]`): the read `to` runs the fold; the build side is vestigial. + * 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 @@ -22,8 +22,20 @@ final class Cata[F[_], S, A](private[zoo] val alg: F[A] => A)(using ) extends Optic[S, Unit, A, Unit, Scheme]: type X = Nothing - private val run: S => A = + private[zoo] val run: S => A = Machines.foldLayered[F, S, A](P.project, (_, fr) => alg(fr)) def to(s: S): Scheme[X, A] = Scheme(run(s)) def from(b: Scheme[X, Unit]): Unit = () + + /** Metamorphism — the fold→unfold seam, **dual to [[Ana.cross]]**'s unfold→fold. Fold `this` to + * the neck value `A`, then unfold it with `ana` into a fresh `G`-recursive `T`. The result + * [[Meta]] reads `S => T` (`.get`). + * + * Unlike [[Ana.cross]] this **does not fuse**: the fold is over `F` and the unfold over a + * possibly-different `G`, so there is no `project ∘ embed` cancellation — the neck `A` is + * genuinely materialised (the [[Meta.X]] is `A`, not `Nothing`). That heterogeneity is exactly + * why `meta` keeps both `Basis`es while `hylo` drops them. + */ + 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/Futu.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala index b4392700..059d05f3 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala @@ -25,7 +25,7 @@ final class Futu[F[_], A, S](private[zoo] val coalg: A => F[Coattr[F, A]])(using ) extends Optic[Unit, S, Unit, A, Scheme]: type X = Coattr[F, A] - private val build: A => S = + private[zoo] val build: A => S = val expand: Coattr[F, A] => F[Coattr[F, A]] = case Coattr.Pure(a) => coalg(a) case Coattr.Roll(layer) => layer @@ -36,9 +36,10 @@ final class Futu[F[_], A, S](private[zoo] val coalg: A => F[Coattr[F, A]])(using def from(b: Scheme[X, A]): S = build(Scheme.value(b)) /** 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, the intermediate `S` is - * never built (mirrors [[Ana.cross]] for the trivial indices). Delegates to [[Schemes.chrono]], - * which needs only `Traverse[F]` — the `Embed`/`Project` carried by `this`/`histo` go unused. + * free monad ([[Coattr]]), the fold the cofree comonad ([[Attr]]); fused, the intermediate `S` + * is never built (mirrors [[Ana.cross]] for the trivial indices). Delegates to + * [[Schemes.chrono]], which needs only `Traverse[F]` — the `Embed`/`Project` carried by + * `this`/`histo` go unused. * * A member (not the generic `Optic.cross`, which would `reverse.andThen` and materialise) so the * fused chrono wins overload resolution. 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 index 31afaba3..e3ca4152 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala @@ -10,12 +10,14 @@ import optics.Optic * Attr[F, A]`**, the cofree comonad `νX. A × F[X]`. This is the thesis at its sharpest: the * histomorphism's existential is *literally* the universal index for folds. `Cata` is the same * optic at the forgetful resolution `X = Nothing`; `Histo` keeps the whole decorated history, so - * `Histo : Cata :: Lens : Getter` — the index refined from the trivial comonad up to the cofree one. + * `Histo : Cata :: Lens : Getter` — the index refined from the trivial comonad up to the cofree + * one. * * `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), so it can read - * arbitrarily far down — folds unreachable by a single-pass [[Cata]]. `Getter`-shaped (`Optic[S, - * Unit, A, Unit, Scheme]`), consumed via `.get`; the read projects the root's head ([[Attr.forget]]). + * subtree ([[Attr.head]] = result, [[Attr.tail]] = the child's own decorated layer), so it can + * read arbitrarily far down — folds unreachable by a single-pass [[Cata]]. `Getter`-shaped + * (`Optic[S, Unit, A, Unit, Scheme]`), consumed via `.get`; the read projects the root's head + * ([[Attr.forget]]). * * Space honesty: course-of-value recursion retains O(n) `Attr` cells by nature. Stack-safe (the * [[Machines.foldLayered]] machine). Heads-only (`alg ∘ map(_.head)`) degenerates to [[Cata]]. @@ -32,5 +34,19 @@ final class Histo[F[_], S, A](private[zoo] val alg: F[Attr[F, A]] => A)(using (_, layer) => Attr(alg(layer), layer), ) - def to(s: S): Scheme[X, A] = Scheme(Attr.forget(toAttr(s))) + private[zoo] val run: S => A = s => Attr.forget(toAttr(s)) + + def to(s: S): Scheme[X, A] = Scheme(run(s)) def from(b: Scheme[X, Unit]): Unit = () + + /** Metamorphism at the universal indices — the **fold→unfold dual of [[Futu.cross]]**'s chrono + * (and the universal-index lift of [[Cata.meta]]). Fold `this` course-of-value to the neck `A`, + * then multi-layer-unfold it with `futu` into a fresh `G`-recursive `T`. Reads `S => T` + * (`.get`). + * + * Like [[Cata.meta]] this **does not fuse** — `F` and `G` differ, so the neck `A` is + * materialised (the [[Meta.X]] is `A`). The cofree history and free multi-layering live on + * either side of that neck, never cancelling across it. + */ + def meta[G[_], T](futu: Futu[G, A, T]): Meta[S, A, T] = + new Meta[S, A, T](run.andThen(futu.build)) 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..0caebba4 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala @@ -0,0 +1,25 @@ +package dev.constructive.eo +package schemes +package zoo + +import optics.Optic + +/** Metamorphism citizen — a **fold-then-unfold** worn as an optic over [[Scheme]], reading `S => T` + * (`Getter`-shaped, consumed via `.get`). 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 thesis stating the obvious honestly: `meta` **cannot fuse**. It folds + * a functor `F` down to `A`, then unfolds a *different* functor `G` back up; with `F ≠ G` there is + * no `project ∘ embed` cancellation to ride, so `A` is genuinely materialised. (Contrast `hylo` / + * `chrono`, whose single shared functor makes the neck cancel — `X = Nothing`.) Built by + * [[Cata.meta]] / [[Histo.meta]] or [[Schemes.meta]] / [[Schemes.metaChrono]]. + * + * @tparam A + * the neck — the retained intermediate value type (the optic's existential `X`) + */ +final class Meta[S, A, T](private[zoo] val run: S => T) extends Optic[S, Unit, T, Unit, Scheme]: + type X = A + def to(s: S): Scheme[X, T] = Scheme(run(s)) + def from(b: Scheme[X, Unit]): Unit = () diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala index ee1c726b..88d2b43d 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala @@ -29,11 +29,13 @@ class ChronoSpec extends Specification: 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 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..59810c78 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala @@ -0,0 +1,136 @@ +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)) + } + + // ----- 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/ZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala index 51504980..28625414 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala @@ -10,8 +10,8 @@ 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). +/** 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`. @@ -77,7 +77,8 @@ class ZooSpec extends Specification: // ----- 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 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) From 63fcf37f971ab522e092ff8cf7f37b6290d5d487 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 14:06:29 +0200 Subject: [PATCH 36/61] =?UTF-8?q?test(schemes):=20F=3D:=3DG=20still=20does?= =?UTF-8?q?=20not=20fuse=20meta=20=E2=80=94=20the=20scalar=20neck=20is=20t?= =?UTF-8?q?he=20barrier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical refutation of the F=:=G fusion hypothesis: a same-functor metamorphism (F = G = BinF, one Basis serving both the fold's Project and the unfold's Embed) STILL calls both project and embed. The functor mismatch was only a sufficient witness for no-fusion; the cause is the scalar neck — the fold's projects (input) are never adjacent to the unfold's embeds (output), so no project∘embed cancels. Contrast hylo, where ana's embed-step and cata's project-step share one layer. Co-Authored-By: Claude Opus 4.8 --- .../constructive/eo/schemes/zoo/Meta.scala | 8 +-- .../constructive/eo/schemes/MetaSpec.scala | 52 ++++++++++++++++--- 2 files changed, 48 insertions(+), 12 deletions(-) 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 index 0caebba4..769baec7 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala @@ -10,10 +10,10 @@ import optics.Optic * existential **`X = A` is the neck** — the intermediate value the fold produces and the unfold * consumes. * - * That non-trivial `X` is the thesis stating the obvious honestly: `meta` **cannot fuse**. It folds - * a functor `F` down to `A`, then unfolds a *different* functor `G` back up; with `F ≠ G` there is - * no `project ∘ embed` cancellation to ride, so `A` is genuinely materialised. (Contrast `hylo` / - * `chrono`, whose single shared functor makes the neck cancel — `X = Nothing`.) Built by + * That non-trivial `X` is the thesis stating the obvious honestly: `meta` **cannot fuse**. It + * folds a functor `F` down to `A`, then unfolds a *different* functor `G` back up; with `F ≠ G` + * there is no `project ∘ embed` cancellation to ride, so `A` is genuinely materialised. (Contrast + * `hylo` / `chrono`, whose single shared functor makes the neck cancel — `X = Nothing`.) Built by * [[Cata.meta]] / [[Histo.meta]] or [[Schemes.meta]] / [[Schemes.metaChrono]]. * * @tparam A diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala index 59810c78..19efb197 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala @@ -15,8 +15,8 @@ import schemes.zoo.{Attr, Coattr} * * `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`). + * 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 @@ -32,16 +32,19 @@ class MetaSpec extends Specification: // 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) @@ -55,7 +58,8 @@ class MetaSpec extends Specification: 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)) + 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 @@ -90,6 +94,37 @@ class MetaSpec extends Specification: (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)" >> { @@ -97,8 +132,8 @@ class MetaSpec extends Specification: 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 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) @@ -110,9 +145,10 @@ class MetaSpec extends Specification: 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 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) From 31de40678a3f96335d1bbaa5d29e7c27c7b7d858 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 14:55:33 +0200 Subject: [PATCH 37/61] =?UTF-8?q?feat(schemes):=20para/apo,=20elgot/coelgo?= =?UTF-8?q?t,=20dyna/codyna=20=E2=80=94=20fill=20the=20zoo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three groups off the cata/ana/hylo/histo/futu/chrono/meta spine: 1. Subterm-retaining schemes (X = the optic complement): - para (Getter, X = F[(S,A)] — store-comonad complement): alg F[(S,A)] => A sees each child's original subterm, not just its summary. Degenerates to cata. (X is the writable-Lens complement; get-put is definitional, put-get needs algebra-coherence, so the lawful put stays a scoped follow-up — shipped as the sound read.) - apo (Review, X = Either[S,A] — Prism residual): coalg A => F[Either[S,A]] grafts a finished subtree by reference (O(1), test pins `eq`). Build-side dual of para. Degenerates to ana. 2. Short-circuit / seed-reading refolds (both fuse → Hylo): - elgot (coalg A => Either[B, F[A]] short-circuits) — wires the previously dead Machines.foldLayeredOr. - coelgot (alg (A, F[B]) => B reads the seed). Both degenerate to hylo. 3. Refold-quadrant diagonals (fuse, Traverse-only): - dyna = ana.cross(histo) (plain unfold → cofree fold) - codyna = futu.cross(cata) (free unfold → plain fold; descriptive name) Added as cross overloads on Ana(Histo)/Futu(Cata) + Schemes ctors. Degenerate to hylo. para re-projects each node to recover subterms (no engine change, no raw-Slot escape). ZooExtendedSpec (11): degeneration laws, the distinguishing capability of each, apo graft-by-reference, dyna/codyna == their seams, 10^6 stack-safety. 47/47 green, formatted. Co-Authored-By: Claude Opus 4.8 --- .../dev/constructive/eo/schemes/Schemes.scala | 87 +++++++++- .../dev/constructive/eo/schemes/zoo/Ana.scala | 9 + .../dev/constructive/eo/schemes/zoo/Apo.scala | 39 +++++ .../constructive/eo/schemes/zoo/Futu.scala | 10 ++ .../constructive/eo/schemes/zoo/Para.scala | 44 +++++ .../eo/schemes/ZooExtendedSpec.scala | 158 ++++++++++++++++++ 6 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala 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 be580b7e..ef796e93 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -5,7 +5,7 @@ import cats.Traverse import data.{Forget, ForgetK} import optics.Optic -import zoo.{Ana, Attr, Cata, Coattr, Futu, Histo, Hylo, Meta} +import zoo.{Ana, Apo, Attr, Cata, Coattr, Futu, Histo, Hylo, Meta, Para} /** 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`. @@ -18,11 +18,16 @@ import zoo.{Ana, Attr, Cata, Coattr, Futu, Histo, Hylo, Meta} * | scheme | `X` | index | * |:----------|:--------------------------------|:----------------------------------------------| * | [[cata]] | `Nothing` | the forgetful (trivial) fold | + * | [[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 | + * | [[apo]] | `Either[S, A]` | the **Prism** residual (graft, build-side) | * | [[futu]] | [[zoo.Coattr]] = `μX. A + F[X]` | the **free monad** (multi-layer unfold) | * - * `histo` refines `cata`'s index up the comonad tower; `futu` refines `ana`'s up the monad tower. + * `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.) * * ==hylo is the fusion, not a primitive — and meta is the honest non-fusion== * @@ -36,15 +41,17 @@ import zoo.{Ana, Attr, Cata, Coattr, Futu, Histo, Hylo, Meta} * and it **cannot fuse**: fold and unfold range over *different* functors, so the neck value is * genuinely 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 [[hylo]] / [[meta]] / - * [[chrono]] / [[metaChrono]]. + * [[chrono]] / [[metaChrono]]; the quadrant's diagonals are [[dyna]] (`ana.cross(histo)`) and + * [[codyna]] (`futu.cross(cata)`). [[elgot]] / [[coelgot]] are the short-circuit / seed-reading + * refold variants (both fuse, driven by [[Machines.foldLayeredOr]] / the seed-passing combine). * * 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. * - * The citizen classes live in [[zoo]] ([[zoo.Cata]] / [[zoo.Ana]] / [[zoo.Hylo]] / [[zoo.Histo]] / - * [[zoo.Futu]] / [[zoo.Meta]]); this object is the user-facing constructor surface plus - * [[fLayer]]. + * The citizen classes live in [[zoo]] ([[zoo.Cata]] / [[zoo.Para]] / [[zoo.Histo]] / [[zoo.Ana]] / + * [[zoo.Apo]] / [[zoo.Futu]] / [[zoo.Hylo]] / [[zoo.Meta]]); this object is the user-facing + * constructor surface plus [[fLayer]]. */ object Schemes: @@ -82,6 +89,15 @@ object Schemes: 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) + /** Paramorphism — a fold retaining the original subterms, `alg: F[(S, A)] => A`, worn as a + * [[zoo.Para]] (`X = F[(S, A)]`). Each child slot pairs its original subterm with its folded + * result, so the algebra can read the subtree itself, not just its summary. Consumed via `.get`. + * Ignoring the `S` half degenerates to [[cata]]. Stack-safe. (See [[zoo.Para]] on why the + * existential is the store-comonad complement but the writable `Lens` put is conditional.) + */ + 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) + /** Anamorphism — an unfold `coalg: Seed => F[Seed]`, worn as an [[zoo.Ana]] (`X = S`). Each step * yields one typed layer of child seeds; [[Embed]] glues each layer into the built `S`. * Materialising — the built `S` is O(nodes). Consumed via `.reverseGet`. Compose onto a fold @@ -98,6 +114,14 @@ object Schemes: 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) + /** Apomorphism — an unfold that may graft a finished subtree, `coalg: A => F[Either[S, A]]`, worn + * as an [[zoo.Apo]] (`X = Either[S, A]`). `Left(s)` grafts a built `S` by reference (O(1), never + * re-walked); `Right(a)` keeps unfolding. The build-side dual of [[para]]. Consumed via + * `.reverseGet`. All-`Right` degenerates to [[ana]]. Stack-safe. + */ + 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) + /** Hylomorphism — the **fused** refold `Seed => A`, building **no intermediate `S`** (so it needs * neither `Project` nor `Embed`, only `Traverse[F]`). Definitionally * `ana(coalg).cross(cata(alg))` — this constructor builds the same one-pass machine directly for @@ -182,3 +206,54 @@ object Schemes: val run = Machines.foldLayered[G, Coattr[G, A], T](expand, (_, gr) => E.embed(gr)) a => run(Coattr.Pure(a)) new Meta[S, A, T](fold.andThen(unfold)) + + /** Dynamorphism — the **fused** plain-unfold → course-of-value-fold refold `A => B`, the + * quadrant-diagonal between [[hylo]] (plain→plain) and [[chrono]] (free→cofree). `coalg` unfolds + * one layer; the fold sees each node's full decorated history ([[zoo.Attr]], the cofree memo). + * Definitionally `ana(coalg).cross(histo(alg))`. Fuses — `Traverse[F]` only, the [[zoo.Attr]] is + * threaded internally with no intermediate `S`. Heads-only `alg` degenerates to [[hylo]]. + * Stack-safe; retains O(n) `Attr` cells. + */ + def dyna[F[_], A, B](coalg: A => F[A], alg: F[Attr[F, B]] => B)(using + F: Traverse[F] + ): Hylo[A, B] = + val build: A => Attr[F, B] = + Machines.foldLayered[F, A, Attr[F, B]](coalg, (_, layer) => Attr(alg(layer), layer)) + new Hylo[A, B](a => Attr.forget(build(a))) + + /** The **fused** multi-layer-unfold → node-blind-fold refold `A => B` — the mirror of [[dyna]], + * opposite diagonal of the refold quadrant. `coalg` may emit several layers per step + * ([[zoo.Coattr]], the free monad); the fold is a plain `cata`. Definitionally + * `futu(coalg).cross(cata(alg))`. Fuses — `Traverse[F]` only, no intermediate `S`. All-`Pure` + * `coalg` degenerates to [[hylo]]. Stack-safe. (`codyna` is a descriptive name — the + * free-unfold/plain-fold refold has no standard one in the literature.) + */ + def codyna[F[_], A, B](coalg: A => F[Coattr[F, A]], alg: F[B] => B)(using + F: Traverse[F] + ): Hylo[A, B] = + val expand: Coattr[F, A] => F[Coattr[F, A]] = + case Coattr.Pure(a) => coalg(a) + case Coattr.Roll(layer) => layer + val run = Machines.foldLayered[F, Coattr[F, A], B](expand, (_, fr) => alg(fr)) + new Hylo[A, B](a => run(Coattr.Pure(a))) + + /** Elgot algorithm — a [[hylo]] whose **unfold may short-circuit**: `coalg: A => Either[B, F[A]]` + * answers `Left(b)` (the seed resolves directly to an answer, stop) or `Right(layer)` (keep + * unfolding); `alg: F[B] => B` folds the rest. Fused refold `A => B` (`Traverse[F]` only, no + * intermediate `S`), driven by the short-circuit-aware [[Machines.foldLayeredOr]]. An + * all-`Right` `coalg` degenerates to [[hylo]]. Stack-safe. + */ + def elgot[F[_], A, B](coalg: A => Either[B, F[A]], alg: F[B] => B)(using + Traverse[F] + ): Hylo[A, B] = + new Hylo[A, B](Machines.foldLayeredOr[F, A, B](coalg, fr => alg(fr))) + + /** Co-Elgot algorithm — a [[hylo]] whose **fold may read the seed**: `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, but on the fused refold). Fused `A => B` (`Traverse[F]` only). + * Ignoring the seed argument degenerates to [[hylo]]. Stack-safe. + */ + def coelgot[F[_], A, B](coalg: A => F[A], alg: (A, F[B]) => B)(using + Traverse[F] + ): Hylo[A, B] = + new Hylo[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/Ana.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala index 535bc2a2..697f4597 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala @@ -34,3 +34,12 @@ final class Ana[F[_], Seed, S](private[zoo] val coalg: Seed => F[Seed])(using */ def cross[B](cata: Cata[F, S, B]): Hylo[Seed, B] = new Hylo[Seed, B](Machines.foldLayered[F, Seed, B](coalg, (_, fr) => cata.alg(fr))(using F)) + + /** The fused **dynamorphism** seam: plain unfold ∘ course-of-value fold ([[Histo]]). The + * refold-quadrant diagonal between [[cross]]'s `hylo` (plain→plain) and [[Futu.cross]]'s + * `chrono` (free→cofree): a plain `ana` unfold whose fold sees each node's full decorated + * history. Fuses — the [[Attr]] cofree memo is threaded internally, no intermediate `S`. + * Delegates to [[Schemes.dyna]] (`Traverse[F]` only; the `Histo`'s `Project` goes unused). + */ + def cross[B](histo: Histo[F, S, B]): Hylo[Seed, B] = + Schemes.dyna[F, Seed, B](coalg, histo.alg)(using F) 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..bd3374b3 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala @@ -0,0 +1,39 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import optics.Optic + +/** Apomorphism citizen — an unfold that may **short-circuit with a finished subtree**, worn as an + * optic over [[Scheme]] 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. + * `Review`-shaped (`Optic[Unit, S, Unit, A, Scheme]`), consumed via `.reverseGet`. + * + * `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]]. + * + * '''O(1) graft.''' A `Left(s)` subtree is placed into its result slot **by reference** — + * [[Machines.foldLayeredOr]]'s `Left` arm returns it without recursing or re-`project`ing — so + * grafting a finished subtree is constant-time, not O(subtree). 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 Optic[Unit, S, Unit, A, Scheme]: + type X = Either[S, A] + + private val build: A => S = + val run = Machines.foldLayeredOr[F, Either[S, A], S]( + { + case Left(s) => Left(s) // finished subtree — grafted by reference, O(1) + case Right(a) => Right(coalg(a)) // seed — keep unfolding + }, + fr => E.embed(fr), + ) + a => run(Right(a)) + + def to(u: Unit): Scheme[X, Unit] = Scheme(()) + def from(b: Scheme[X, A]): S = build(Scheme.value(b)) 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 index 059d05f3..8029fd9e 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala @@ -46,3 +46,13 @@ final class Futu[F[_], A, S](private[zoo] val coalg: A => F[Coattr[F, A]])(using */ def cross[B](histo: Histo[F, S, B]): Hylo[A, B] = Schemes.chrono[F, A, B](coalg, histo.alg)(using F) + + /** The fused mirror-of-dyna seam: multi-layer unfold ∘ node-blind fold ([[Cata]]). The + * refold-quadrant diagonal opposite [[Ana.cross]]'s `dyna`: a free-monad `futu` unfold whose + * fold is a plain `cata`. Fuses — the [[Coattr]] free layers are threaded internally, no + * intermediate `S`. Delegates to [[Schemes.codyna]] (`Traverse[F]` only; the `Cata`'s `Project` + * goes unused). (`codyna` is a descriptive name; the free-unfold/plain-fold refold has no + * standard one.) + */ + def cross[B](cata: Cata[F, S, B]): Hylo[A, B] = + Schemes.codyna[F, A, B](coalg, cata.alg)(using F) 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..620487c7 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala @@ -0,0 +1,44 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import optics.Optic + +/** Paramorphism citizen — a fold that **retains the original subterms**, worn as an optic over + * [[Scheme]] with **`X = F[(S, A)]`**: each child slot pairs the original subterm `S` with its + * folded result `A`. `Getter`-shaped (`Optic[S, Unit, A, Unit, Scheme]`), consumed via `.get`. + * + * `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 (e.g. "keep the larger of each child's *original* subtree"). + * 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`). + * The get-put direction 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. + * + * The subterms are recovered by re-`project`ing each node (one extra peel per node) and zipping with + * the children's results in `Foldable` order — sound for any lawful `Traverse`. Stack-safe (the + * [[Machines.foldLayered]] machine). + */ +final class Para[F[_], S, A](private[zoo] val alg: F[(S, A)] => A)(using + F: Traverse[F], + P: Project[F, S], +) extends Optic[S, Unit, A, Unit, Scheme]: + type X = F[(S, A)] + + private val run: S => A = + Machines.foldLayered[F, S, A]( + P.project, + (s, fa) => + // pair each child's original subterm (re-projected) with its folded result, in order. + val it = F.toList(fa).iterator + alg(F.map(P.project(s))(sub => (sub, it.next()))), + ) + + def to(s: S): Scheme[X, A] = Scheme(run(s)) + def from(b: Scheme[X, Unit]): Unit = () 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..7c789956 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala @@ -0,0 +1,158 @@ +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 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 + 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) + 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.para[BinF, Bin, Int](paraDepth).get(b) == Deep) + .and(Schemes.cata[BinF, Bin, Int](sumLeaves).get(deep) == 0) // all leaves are 0 + } From c36efafac2a1dd91c6a18ab1347e589455df3371 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 15:37:46 +0200 Subject: [PATCH 38/61] refactor(schemes): drop Scheme carrier for Direct; one class per scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up addressing two challenges: 1. Scheme carrier was redundant. Direct[X,A]=A is value-identical and strictly more capable (it already has Accessor+ReverseAccessor PLUS ForgetfulFold/ Applicative/Traverse + AssociativeFunctor). The original "Direct can't fuse" rationale conflated the Getter/Review *classes* (which store opaque closures) with the Direct *carrier* — fusion comes from the citizens carrying their (co)algebra + cross being a member, which is carrier-independent. Deleted Scheme.scala; all citizens now extend Optic[...,Direct]. Bonus: scheme reads compose with core Getters directly (getter.andThen(cata)) — the .readOnly bridge is gone. 2. One final class + file per scheme (uniform with the primitives). New: Chrono, Dyna, Codyna, Elgot, Coelgot, MetaChrono — each a final class storing its run/refold fn with the machine-wiring in its companion. Schemes is now a thin factory listing of one-line delegations. cross seams return the named type (Ana.cross(Histo)→Dyna, Futu.cross(Histo)→Chrono, Futu.cross(Cata)→ Codyna, Histo.meta(Futu)→MetaChrono). Honesty: the fused-refold classes (Chrono/Dyna/Codyna/Elgot/Coelgot) share X=Nothing — fusion discards the index, so they are nominally-distinct named types, not distinct existentials. Documented as such rather than faking a per-refold X. The genuine indices stay with cata/para/histo/ana/apo/futu/meta. final-class-stores-fn (not an abstract base) is deliberate — matches core Getter/Review and avoids the documented ~1.8x megamorphic-dispatch penalty. Closed two review gaps: a deep (>512) apo graft test exercising the heapWalk Left arm, and a wide/variadic-functor (RoseF) para zip-alignment test. Bounded the 10^6 para+apo test's peak heap (scope each tree) to kill a GC-pressure flake. 49/49 green, formatted. foldLayeredM kept as reserved (per decision). Co-Authored-By: Claude Opus 4.8 --- .../dev/constructive/eo/schemes/Scheme.scala | 52 ---- .../dev/constructive/eo/schemes/Schemes.scala | 263 ++++++------------ .../dev/constructive/eo/schemes/zoo/Ana.scala | 43 ++- .../dev/constructive/eo/schemes/zoo/Apo.scala | 14 +- .../constructive/eo/schemes/zoo/Cata.scala | 16 +- .../constructive/eo/schemes/zoo/Chrono.scala | 41 +++ .../constructive/eo/schemes/zoo/Codyna.scala | 35 +++ .../constructive/eo/schemes/zoo/Coelgot.scala | 30 ++ .../constructive/eo/schemes/zoo/Dyna.scala | 33 +++ .../constructive/eo/schemes/zoo/Elgot.scala | 31 +++ .../constructive/eo/schemes/zoo/Futu.scala | 53 ++-- .../constructive/eo/schemes/zoo/Histo.scala | 25 +- .../constructive/eo/schemes/zoo/Hylo.scala | 36 ++- .../constructive/eo/schemes/zoo/Meta.scala | 46 ++- .../eo/schemes/zoo/MetaChrono.scala | 49 ++++ .../constructive/eo/schemes/zoo/Para.scala | 34 +-- .../constructive/eo/schemes/FusionSpec.scala | 4 +- .../constructive/eo/schemes/SchemesSpec.scala | 4 +- .../eo/schemes/ZooExtendedSpec.scala | 82 ++++-- 19 files changed, 527 insertions(+), 364 deletions(-) delete mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Chrono.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Codyna.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coelgot.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Dyna.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Elgot.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/MetaChrono.scala diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala deleted file mode 100644 index 277730fd..00000000 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Scheme.scala +++ /dev/null @@ -1,52 +0,0 @@ -package dev.constructive.eo -package schemes - -import accessor.{Accessor, ReverseAccessor} - -/** The recursion-scheme carrier. At the value level `Scheme[X, A] = A` (the focus); `X` is a - * phantom at runtime but a *load-bearing type-level index* at the optic level — it is the thesis - * of this module made into a type. - * - * ==Why a distinct carrier (not `Direct`)== - * - * The plain `Direct`-backed [[dev.constructive.eo.optics.Getter]] / - * [[dev.constructive.eo.optics.Review]] collapse a scheme to an opaque closure (`S => A` / - * `Seed => S`), throwing the algebra away. Once the `coalg`/`alg` are gone the - * `project ∘ embed = id` cancellation that deforestation rides on is *invisible*, so - * `ana.cross(cata)` over `Direct` can only materialise the whole `S` and then fold it. The scheme - * citizens ([[Cata]], [[Ana]]) instead **carry their (co)algebra**, so the fused [[Ana.cross]] can - * rebuild the one-pass machine — `hylo` with no intermediate `S`. - * - * ==The existential `X` is the index== - * - * `X` records what structure a scheme *retains* — the soundness condition for fusion: - * - * - [[Cata]] : `X = Nothing` — a node-blind fold (`alg: F[A] => A`). It retains nothing of the - * source tree, so `ana.cross(cata)` is sound to **fuse** (deforest). - * - [[Ana]] : `X = S` — the unfold threads the built structure. - * - [[Histo]] : `X = Attr[F, A]` (cofree comonad), [[Futu]] : `X = Coattr[F, A]` (free monad) — - * the universal indices, refining the fold/unfold towers. - * - [[Meta]] : `X = A` — the metamorphism's neck; non-trivial precisely because a fold→unfold - * over *different* functors cannot deforest, the honest mirror of [[Hylo]]'s `X = Nothing`. - * - * Refining `X` upward trades allocation for capability; the `(co)free (co)monads` are the - * universal such indices. - * - * Value-identical to `Direct`; kept separate so the scheme optics are honest citizens and so the - * fused [[Ana.cross]] / [[Futu.cross]] members are reachable in overload resolution. - */ -opaque type Scheme[X, A] = A - -object Scheme: - - inline def apply[X, A](a: A): Scheme[X, A] = a - - extension [X, A](s: Scheme[X, A]) inline def value: A = s - - /** Reads the focus out — powers `.get` on [[Cata]] / [[Hylo]]. */ - given Accessor[Scheme] with - def get[X, A](fa: Scheme[X, A]): A = fa - - /** Wraps a focus in — powers `.reverseGet` on [[Ana]]. */ - given ReverseAccessor[Scheme] with - def reverseGet[X, A](a: A): Scheme[X, A] = a 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 ef796e93..85f82b13 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -5,15 +5,16 @@ import cats.Traverse import data.{Forget, ForgetK} import optics.Optic -import zoo.{Ana, Apo, Attr, Cata, Coattr, Futu, Histo, Hylo, Meta, Para} +import zoo.* /** 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 [[Optic]] whose existential `X` (see [[Scheme]]) is the *index* of the - * recursion — what the scheme retains — and **the (co)free (co)monads are the universal indices**: + * A recursion scheme is an [[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 | * |:----------|:--------------------------------|:----------------------------------------------| @@ -32,26 +33,26 @@ import zoo.{Ana, Apo, Attr, Cata, Coattr, Futu, Histo, Hylo, Meta, Para} * ==hylo is the fusion, not a primitive — and meta is the honest non-fusion== * * [[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** over - * the [[Scheme]] carrier — which keeps the `coalg`/`alg` alive — into [[hylo]], building *no - * intermediate `S`*. The [[FusionSpec]] pins the hylo law and witnesses the deforestation (the - * fused refold never calls `project`/`embed`). + * 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→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 - * genuinely 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 [[hylo]] / [[meta]] / - * [[chrono]] / [[metaChrono]]; the quadrant's diagonals are [[dyna]] (`ana.cross(histo)`) and - * [[codyna]] (`futu.cross(cata)`). [[elgot]] / [[coelgot]] are the short-circuit / seed-reading - * refold variants (both fuse, driven by [[Machines.foldLayeredOr]] / the seed-passing combine). + * 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. * - * 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. + * ==Shape== * - * The citizen classes live in [[zoo]] ([[zoo.Cata]] / [[zoo.Para]] / [[zoo.Histo]] / [[zoo.Ana]] / - * [[zoo.Apo]] / [[zoo.Futu]] / [[zoo.Hylo]] / [[zoo.Meta]]); this object is the user-facing - * constructor surface plus [[fLayer]]. + * 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: @@ -60,8 +61,8 @@ object Schemes: * S`, so it is a genuine `Optic[S, S, S, S, Forget[F]]` with **no change to the `Optic` trait** * — the concrete proof that a typed `F` is an optic carrier, and an observational read (given * `Foldable[F]`) of a layer's immediate foci via `.foldMap`. It is a single-layer peel/glue - * (like `Plated`'s `plate`, but one layer, not the recursion); the schemes below drive - * `to`/`from` themselves. + * (like `Plated`'s `plate`, but one layer, not the recursion); the schemes drive `to`/`from` + * themselves. */ def fLayer[F[_], S](using Project[F, S], Embed[F, S]): Optic[S, S, S, S, Forget[F]] = new FLayer[F, S] @@ -73,187 +74,107 @@ object Schemes: def to(s: S): Forget[F][X, S] = ForgetK(P.project(s)) def from(fs: Forget[F][X, S]): S = E.embed(fs.value) - /** Catamorphism — a **node-blind** fold `alg: F[A] => A`, worn as a [[zoo.Cata]] (`X = Nothing`). - * The algebra sees only the already-folded children as a typed `F[A]` (named constructors — no - * positional `AnyRef` indexing), never the original node `S`; that blindness is what makes - * `ana.cross(cata)` sound to fuse. Consumed via `.get`. Stack-safe. - */ + // ===== 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) - /** Histomorphism — a course-of-value fold `alg: F[Attr[F, A]] => A`, worn as a [[zoo.Histo]] (`X = - * Attr[F, A]`, the cofree comonad). Each child slot carries its full decorated history - * ([[zoo.Attr]]: result + that child's own decorated layer), so the algebra can read arbitrarily - * far down. Consumed via `.get`. Stack-safe; retains O(n) `Attr` cells by nature. - */ - 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) - - /** Paramorphism — a fold retaining the original subterms, `alg: F[(S, A)] => A`, worn as a - * [[zoo.Para]] (`X = F[(S, A)]`). Each child slot pairs its original subterm with its folded - * result, so the algebra can read the subtree itself, not just its summary. Consumed via `.get`. - * Ignoring the `S` half degenerates to [[cata]]. Stack-safe. (See [[zoo.Para]] on why the - * existential is the store-comonad complement but the writable `Lens` put is conditional.) + /** 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) - /** Anamorphism — an unfold `coalg: Seed => F[Seed]`, worn as an [[zoo.Ana]] (`X = S`). Each step - * yields one typed layer of child seeds; [[Embed]] glues each layer into the built `S`. - * Materialising — the built `S` is O(nodes). Consumed via `.reverseGet`. Compose onto a fold - * with `ana.cross(cata)` (the fused build⇄read seam — that is [[hylo]]). Stack-safe. + /** 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) + + // ===== 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) - /** Futumorphism — a multi-layer unfold `coalg: A => F[Coattr[F, A]]`, worn as a [[zoo.Futu]] (`X = - * Coattr[F, A]`, the free monad). Each slot answers [[zoo.Coattr.Pure]] (keep unfolding) or - * [[zoo.Coattr.Roll]] (a prebuilt layer, no coalgebra call), so one step may emit several - * layers. Consumed via `.reverseGet`. Stack-safe. - */ - 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) - - /** Apomorphism — an unfold that may graft a finished subtree, `coalg: A => F[Either[S, A]]`, worn - * as an [[zoo.Apo]] (`X = Either[S, A]`). `Left(s)` grafts a built `S` by reference (O(1), never - * re-walked); `Right(a)` keeps unfolding. The build-side dual of [[para]]. Consumed via - * `.reverseGet`. All-`Right` degenerates to [[ana]]. Stack-safe. + /** 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) - /** Hylomorphism — the **fused** refold `Seed => A`, building **no intermediate `S`** (so it needs - * neither `Project` nor `Embed`, only `Traverse[F]`). Definitionally - * `ana(coalg).cross(cata(alg))` — this constructor builds the same one-pass machine directly for - * callers who never name the intermediate type. `coalg` unfolds a seed into one typed layer; - * `alg` folds the layer's results to `A` (node-blind, like [[cata]]). Stack-safe. + /** 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 hylo[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))) + 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) - /** Chronomorphism — the **fused** futu-then-histo refold `A => B`, **hylo lifted to the universal - * indices**: it unfolds through the free monad ([[zoo.Coattr]]) and folds through the cofree - * comonad ([[zoo.Attr]]), building **no intermediate `S`**. Definitionally `futu(coalg).cross( - * histo(algebra))`; this constructor builds the same one-pass machine directly. - * - * Like [[hylo]] it needs **only `Traverse[F]`** — no `Project`, no `Embed` — which is the - * compile-time deforestation proof: with no `Basis` in scope there is no `S` to build. The - * `Coattr` (multi-layer input) and `Attr` (course-of-value history) cells are threaded - * internally; only the root head is read out. Heads-only `algebra` + all-`Pure` `coalg` - * degenerate to [[hylo]]. Stack-safe (the [[Machines.foldLayered]] machine); retains O(n) `Attr` - * cells by nature. - */ - def chrono[F[_], A, B]( - coalg: A => F[Coattr[F, A]], - algebra: F[Attr[F, B]] => B, - )(using F: Traverse[F]): Hylo[A, B] = - val expand: Coattr[F, A] => F[Coattr[F, A]] = - case Coattr.Pure(a) => coalg(a) - case Coattr.Roll(layer) => layer - val build: Coattr[F, A] => Attr[F, B] = - Machines.foldLayered[F, Coattr[F, A], Attr[F, B]]( - expand, - (_, layer) => Attr(algebra(layer), layer), - ) - new Hylo[A, B](a => Attr.forget(build(Coattr.Pure(a)))) + // ===== Refolds (fused — Traverse[F] only, no intermediate S) =============================== - /** Metamorphism — the **fold-then-unfold** read `S => T`, the direction-dual of [[hylo]]. Fold - * the `F`-recursive `S` to a neck value `A` (node-blind `alg`), then unfold `A` into a fresh - * `G`-recursive `T` (`coalg`). Definitionally `cata(alg).meta(ana(coalg))`; this builds the same - * two-pass machine directly. - * - * **Does not fuse** — unlike [[hylo]] it keeps *both* `Basis`es (`Project[F, S]` to fold, - * `Embed[G, T]` to build), because the fold's `F` and the unfold's `G` differ: there is no - * shared functor whose `project ∘ embed` could cancel, so the neck `A` is genuinely materialised - * (the [[zoo.Meta]]'s existential `X = A`). The compile-time tell is right here in the - * signature: where `hylo` needs only `Traverse`, `meta` cannot drop either `Basis`. Stack-safe - * (two [[Machines.foldLayered]] passes). + /** Hylomorphism — the fused unfold→fold `Seed => A` ([[zoo.Hylo]]). Definitionally + * `ana(coalg).cross(cata(alg))`. */ - def meta[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.foldLayered[G, A, T](coalg, (_, gr) => E.embed(gr)) - new Meta[S, A, T](fold.andThen(unfold)) + def hylo[F[_], Seed, A](coalg: Seed => F[Seed], alg: F[A] => A)(using + Traverse[F] + ): Hylo[Seed, A] = + Hylo(coalg, alg) - /** Metamorphism at the universal indices — the **fold-then-unfold dual of [[chrono]]**. Fold the - * `F`-recursive `S` course-of-value to a neck `A` (the cofree history, [[zoo.Attr]]), then - * multi-layer-unfold `A` into a `G`-recursive `T` (the free coalgebra, [[zoo.Coattr]]). - * Definitionally `histo(algebra).meta(futu(coalg))`. - * - * **Does not fuse**, for the same reason as [[meta]]: `F ≠ G`, so the neck `A` is materialised - * (`X = A`). 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. - * Stack-safe. + /** Dynamorphism — the fused plain-unfold → cofree-fold `A => B` ([[zoo.Dyna]]). Definitionally + * `ana(coalg).cross(histo(alg))`. */ - def metaChrono[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]): Meta[S, A, T] = - val fold: S => A = - val toAttr = Machines.foldLayered[F, S, Attr[F, A]]( - P.project, - (_, layer) => Attr(algebra(layer), layer), - ) - s => Attr.forget(toAttr(s)) - val expand: Coattr[G, A] => G[Coattr[G, A]] = - case Coattr.Pure(a) => coalg(a) - case Coattr.Roll(layer) => layer - val unfold: A => T = - val run = Machines.foldLayered[G, Coattr[G, A], T](expand, (_, gr) => E.embed(gr)) - a => run(Coattr.Pure(a)) - new Meta[S, A, T](fold.andThen(unfold)) + def dyna[F[_], A, B](coalg: A => F[A], alg: F[Attr[F, B]] => B)(using Traverse[F]): Dyna[A, B] = + Dyna(coalg, alg) - /** Dynamorphism — the **fused** plain-unfold → course-of-value-fold refold `A => B`, the - * quadrant-diagonal between [[hylo]] (plain→plain) and [[chrono]] (free→cofree). `coalg` unfolds - * one layer; the fold sees each node's full decorated history ([[zoo.Attr]], the cofree memo). - * Definitionally `ana(coalg).cross(histo(alg))`. Fuses — `Traverse[F]` only, the [[zoo.Attr]] is - * threaded internally with no intermediate `S`. Heads-only `alg` degenerates to [[hylo]]. - * Stack-safe; retains O(n) `Attr` cells. + /** Codynamorphism — the fused free-unfold → node-blind-fold `A => B` ([[zoo.Codyna]], the mirror + * of [[dyna]]). Definitionally `futu(coalg).cross(cata(alg))`. */ - def dyna[F[_], A, B](coalg: A => F[A], alg: F[Attr[F, B]] => B)(using - F: Traverse[F] - ): Hylo[A, B] = - val build: A => Attr[F, B] = - Machines.foldLayered[F, A, Attr[F, B]](coalg, (_, layer) => Attr(alg(layer), layer)) - new Hylo[A, B](a => Attr.forget(build(a))) + def codyna[F[_], A, B](coalg: A => F[Coattr[F, A]], alg: F[B] => B)(using + Traverse[F] + ): Codyna[A, B] = Codyna(coalg, alg) - /** The **fused** multi-layer-unfold → node-blind-fold refold `A => B` — the mirror of [[dyna]], - * opposite diagonal of the refold quadrant. `coalg` may emit several layers per step - * ([[zoo.Coattr]], the free monad); the fold is a plain `cata`. Definitionally - * `futu(coalg).cross(cata(alg))`. Fuses — `Traverse[F]` only, no intermediate `S`. All-`Pure` - * `coalg` degenerates to [[hylo]]. Stack-safe. (`codyna` is a descriptive name — the - * free-unfold/plain-fold refold has no standard one in the literature.) + /** Chronomorphism — the fused free-unfold → cofree-fold `A => B` ([[zoo.Chrono]]), [[hylo]] at + * the universal indices. Definitionally `futu(coalg).cross(histo(algebra))`. */ - def codyna[F[_], A, B](coalg: A => F[Coattr[F, A]], alg: F[B] => B)(using - F: Traverse[F] - ): Hylo[A, B] = - val expand: Coattr[F, A] => F[Coattr[F, A]] = - case Coattr.Pure(a) => coalg(a) - case Coattr.Roll(layer) => layer - val run = Machines.foldLayered[F, Coattr[F, A], B](expand, (_, fr) => alg(fr)) - new Hylo[A, B](a => run(Coattr.Pure(a))) + 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 algorithm — a [[hylo]] whose **unfold may short-circuit**: `coalg: A => Either[B, F[A]]` - * answers `Left(b)` (the seed resolves directly to an answer, stop) or `Right(layer)` (keep - * unfolding); `alg: F[B] => B` folds the rest. Fused refold `A => B` (`Traverse[F]` only, no - * intermediate `S`), driven by the short-circuit-aware [[Machines.foldLayeredOr]]. An - * all-`Right` `coalg` degenerates to [[hylo]]. Stack-safe. + /** 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] - ): Hylo[A, B] = - new Hylo[A, B](Machines.foldLayeredOr[F, A, B](coalg, fr => alg(fr))) + ): Elgot[A, B] = + Elgot(coalg, alg) - /** Co-Elgot algorithm — a [[hylo]] whose **fold may read the seed**: `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, but on the fused refold). Fused `A => B` (`Traverse[F]` only). - * Ignoring the seed argument degenerates to [[hylo]]. Stack-safe. + /** 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] - ): Hylo[A, B] = - new Hylo[A, B](Machines.foldLayered[F, A, B](coalg, (a, fr) => alg(a, fr))) + ): 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) 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 index 697f4597..39647f18 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala @@ -4,42 +4,37 @@ package zoo import cats.Traverse +import data.Direct import optics.Optic -/** Anamorphism citizen — an unfold worn as an optic over [[Scheme]] with `X = S` (the structure it - * threads). `Review`-shaped (`Optic[Unit, S, Unit, Seed, Scheme]`): the build `from` runs the - * unfold; the read side is vestigial. Carries `coalg` so [[cross]] can fuse with a node-blind - * [[Cata]]. Refining `X` upward to [[Coattr]] = `μX. Seed + F[X]` (the free monad) gives the - * multi-layer unfold (futumorphism — see [[Futu]]). +/** Anamorphism citizen — an unfold worn as an optic over [[dev.constructive.eo.data.Direct]] with + * `X = S` (the structure it threads). `Review`-shaped (`Optic[Unit, S, Unit, Seed, Direct]`): the + * build `from` runs the unfold; the read side is vestigial. 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 — see [[Futu]]). */ final class Ana[F[_], Seed, S](private[zoo] val coalg: Seed => F[Seed])(using F: Traverse[F], E: Embed[F, S], -) extends Optic[Unit, S, Unit, Seed, Scheme]: +) extends Optic[Unit, S, Unit, Seed, Direct]: type X = S private[zoo] val build: Seed => S = Machines.foldLayered[F, Seed, S](coalg, (_, fr) => E.embed(fr)) - def to(u: Unit): Scheme[X, Unit] = Scheme(()) - def from(b: Scheme[X, Seed]): S = build(Scheme.value(b)) + def to(u: Unit): Direct[X, Unit] = Direct(()) + def from(b: Direct[X, Seed]): S = build(Direct.value(b)) - /** The fused hylo seam: ana ∘ a **node-blind** [[Cata]]. Because the fold retains nothing of the - * tree (`X = Nothing`), deforestation is sound — rebuild the one-pass [[Machines.foldLayered]] - * machine from `this.coalg` + `cata.alg`, building **no intermediate `S`**. This is what makes - * `ana.cross(cata)` *be* [[Schemes.hylo]] rather than a materialise-then-fold. - * - * A member (not the generic `Optic.cross` extension, which would `reverse.andThen` into a - * materialising read) so it wins overload resolution and the fusion is the default. + /** 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] = - new Hylo[Seed, B](Machines.foldLayered[F, Seed, B](coalg, (_, fr) => cata.alg(fr))(using F)) + 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` (plain→plain) and [[Futu.cross]]'s - * `chrono` (free→cofree): a plain `ana` unfold whose fold sees each node's full decorated - * history. Fuses — the [[Attr]] cofree memo is threaded internally, no intermediate `S`. - * Delegates to [[Schemes.dyna]] (`Traverse[F]` only; the `Histo`'s `Project` goes unused). + /** 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]): Hylo[Seed, B] = - Schemes.dyna[F, Seed, B](coalg, histo.alg)(using F) + 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 index bd3374b3..c1131ef5 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala @@ -4,12 +4,14 @@ package zoo import cats.Traverse +import data.Direct import optics.Optic /** Apomorphism citizen — an unfold that may **short-circuit with a finished subtree**, worn as an - * optic over [[Scheme]] 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. - * `Review`-shaped (`Optic[Unit, S, Unit, A, Scheme]`), consumed via `.reverseGet`. + * optic over [[dev.constructive.eo.data.Direct]] 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. `Review`-shaped (`Optic[Unit, S, Unit, A, Direct]`), consumed via + * `.reverseGet`. * * `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 @@ -22,7 +24,7 @@ import optics.Optic final class Apo[F[_], A, S](private[zoo] val coalg: A => F[Either[S, A]])(using F: Traverse[F], E: Embed[F, S], -) extends Optic[Unit, S, Unit, A, Scheme]: +) extends Optic[Unit, S, Unit, A, Direct]: type X = Either[S, A] private val build: A => S = @@ -35,5 +37,5 @@ final class Apo[F[_], A, S](private[zoo] val coalg: A => F[Either[S, A]])(using ) a => run(Right(a)) - def to(u: Unit): Scheme[X, Unit] = Scheme(()) - def from(b: Scheme[X, A]): S = build(Scheme.value(b)) + def to(u: Unit): Direct[X, Unit] = Direct(()) + def from(b: Direct[X, A]): S = build(Direct.value(b)) 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 index df50ff4c..b6c4666a 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala @@ -4,12 +4,14 @@ package zoo import cats.Traverse +import data.Direct import optics.Optic -/** Catamorphism citizen — a **node-blind** fold worn as an optic over [[Scheme]] with - * `X = Nothing`, the forgetful (trivial) resolution of the recursion index. `Getter`-shaped - * (`Optic[S, Unit, A, Unit, Scheme]`): the read `to` runs the fold; the build side is vestigial. - * Carries `alg` so [[Ana.cross]] can rebuild the fused [[Hylo]] machine. +/** Catamorphism citizen — a **node-blind** fold worn as an optic over + * [[dev.constructive.eo.data.Direct]] with `X = Nothing`, the forgetful (trivial) resolution of + * the recursion index. `Getter`-shaped (`Optic[S, Unit, A, Unit, Direct]`): the read `to` runs the + * fold; the build side is vestigial. 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 @@ -19,14 +21,14 @@ import optics.Optic final class Cata[F[_], S, A](private[zoo] val alg: F[A] => A)(using F: Traverse[F], P: Project[F, S], -) extends Optic[S, Unit, A, Unit, Scheme]: +) extends Optic[S, Unit, A, Unit, Direct]: type X = Nothing private[zoo] val run: S => A = Machines.foldLayered[F, S, A](P.project, (_, fr) => alg(fr)) - def to(s: S): Scheme[X, A] = Scheme(run(s)) - def from(b: Scheme[X, Unit]): Unit = () + def to(s: S): Direct[X, A] = Direct(run(s)) + def from(b: Direct[X, Unit]): Unit = () /** Metamorphism — the fold→unfold seam, **dual to [[Ana.cross]]**'s unfold→fold. Fold `this` to * the neck value `A`, then unfold it with `ana` into a fresh `G`-recursive `T`. The result 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..f8b1015c --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Chrono.scala @@ -0,0 +1,41 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import data.Direct +import optics.Optic + +/** Chronomorphism citizen — the **fused** futu-then-histo refold, [[Hylo]] lifted to the universal + * indices: unfold through the free monad ([[Coattr]]), fold through the cofree comonad ([[Attr]]), + * building no intermediate `S`. `Getter`-shaped over [[dev.constructive.eo.data.Direct]], consumed + * via `.get`. Built by [[Futu.cross]] or [[Chrono.apply]]. + * + * A nominally-distinct member of the fused-refold family (see [[Hylo]]): honest `X = Nothing`, since + * fusion discards the `Coattr`/`Attr` it threads internally. + */ +final class Chrono[A, B] private[zoo] (private[zoo] val refold: A => B) + extends Optic[A, Unit, B, Unit, Direct]: + type X = Nothing + def to(a: A): Direct[X, B] = Direct(refold(a)) + def from(b: Direct[X, Unit]): Unit = () + +object Chrono: + + /** The fused free→cofree refold `A => B`, needing only `Traverse[F]`. 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 expand: Coattr[F, A] => F[Coattr[F, A]] = + case Coattr.Pure(a) => coalg(a) + case Coattr.Roll(layer) => layer + val build: Coattr[F, A] => Attr[F, B] = + Machines.foldLayered[F, Coattr[F, A], Attr[F, B]]( + expand, + (_, layer) => Attr(algebra(layer), layer), + ) + new Chrono[A, B](a => Attr.forget(build(Coattr.Pure(a)))) 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..9b42ea04 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Codyna.scala @@ -0,0 +1,35 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import data.Direct +import optics.Optic + +/** The fused multi-layer-unfold → node-blind-fold refold — the mirror of [[Dyna]], opposite diagonal + * of the refold quadrant: a free-monad `futu` unfold ([[Coattr]]) whose fold is a plain `cata`, built + * with no intermediate `S`. `Getter`-shaped over [[dev.constructive.eo.data.Direct]], `.get`. 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[zoo] val refold: A => B) + extends Optic[A, Unit, B, Unit, Direct]: + type X = Nothing + def to(a: A): Direct[X, B] = Direct(refold(a)) + def from(b: Direct[X, Unit]): Unit = () + +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 expand: Coattr[F, A] => F[Coattr[F, A]] = + case Coattr.Pure(a) => coalg(a) + case Coattr.Roll(layer) => layer + val run = Machines.foldLayered[F, Coattr[F, A], B](expand, (_, 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..2af76d97 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coelgot.scala @@ -0,0 +1,30 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import data.Direct +import optics.Optic + +/** Co-Elgot citizen — a [[Hylo]] whose **fold may read the seed**: `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). Fused `A => B`, `Traverse[F]` only. `Getter`-shaped over + * [[dev.constructive.eo.data.Direct]], `.get`. Built by [[Coelgot.apply]]. Nominally distinct in the + * fused-refold family (see [[Hylo]]): honest `X = Nothing`. + */ +final class Coelgot[A, B] private[zoo] (private[zoo] val refold: A => B) + extends Optic[A, Unit, B, Unit, Direct]: + type X = Nothing + def to(a: A): Direct[X, B] = Direct(refold(a)) + def from(b: Direct[X, Unit]): Unit = () + +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/Dyna.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Dyna.scala new file mode 100644 index 00000000..95cda4d7 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Dyna.scala @@ -0,0 +1,33 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import data.Direct +import optics.Optic + +/** Dynamorphism citizen — the **fused** plain-unfold → course-of-value-fold refold, the + * refold-quadrant diagonal between [[Hylo]] (plain→plain) and [[Chrono]] (free→cofree). A plain + * `ana` unfold whose fold sees each node's full decorated history ([[Attr]], the cofree memo), built + * with no intermediate `S`. `Getter`-shaped over [[dev.constructive.eo.data.Direct]], `.get`. 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[zoo] val refold: A => B) + extends Optic[A, Unit, B, Unit, Direct]: + type X = Nothing + def to(a: A): Direct[X, B] = Direct(refold(a)) + def from(b: Direct[X, Unit]): Unit = () + +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, (_, layer) => Attr(alg(layer), layer)) + 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..f535bea2 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Elgot.scala @@ -0,0 +1,31 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import data.Direct +import optics.Optic + +/** Elgot citizen — a [[Hylo]] whose **unfold may short-circuit**: `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. Fused refold `A => B` (no intermediate `S`), driven by the + * short-circuit-aware [[Machines.foldLayeredOr]]. `Getter`-shaped over + * [[dev.constructive.eo.data.Direct]], `.get`. Built by [[Elgot.apply]]. Nominally distinct in the + * fused-refold family (see [[Hylo]]): honest `X = Nothing`. + */ +final class Elgot[A, B] private[zoo] (private[zoo] val refold: A => B) + extends Optic[A, Unit, B, Unit, Direct]: + type X = Nothing + def to(a: A): Direct[X, B] = Direct(refold(a)) + def from(b: Direct[X, Unit]): Unit = () + +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/Futu.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala index 8029fd9e..b349a223 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala @@ -4,25 +4,22 @@ package zoo import cats.Traverse +import data.Direct import optics.Optic -/** Futumorphism citizen — a multi-layer unfold worn as an optic over [[Scheme]] with **`X = - * Coattr[F, A]`**, the free monad `μX. A + F[X]`. The build-side mirror of [[Histo]]: where the - * cofree comonad is the universal index for folds, the free monad is the universal index for - * unfolds. `Ana` is the same optic at the resolution `X = S`; `Futu` refines it so the coalgebra - * may emit several layers per step. - * - * `coalg: A => F[Coattr[F, A]]` answers each child slot with either [[Coattr.Pure]] (a seed the - * engine keeps unfolding) or [[Coattr.Roll]] (a prebuilt layer unrolled with **no** further - * coalgebra call) — so one step can produce more than one layer of structure. `Review`-shaped - * (`Optic[Unit, S, Unit, A, Scheme]`), consumed via `.reverseGet`; the root seed enters as - * `Coattr.Pure`. Stack-safe (the [[Machines.foldLayered]] machine). An all-`Pure` coalgebra - * (`map(coalg(_))(Coattr.Pure(_))`) degenerates to [[Ana]]. +/** Futumorphism citizen — a multi-layer unfold worn as an optic over + * [[dev.constructive.eo.data.Direct]] 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. `Review`-shaped (`Optic[Unit, S, Unit, A, Direct]`), consumed + * via `.reverseGet`; 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 Optic[Unit, S, Unit, A, Scheme]: +) extends Optic[Unit, S, Unit, A, Direct]: type X = Coattr[F, A] private[zoo] val build: A => S = @@ -32,27 +29,17 @@ final class Futu[F[_], A, S](private[zoo] val coalg: A => F[Coattr[F, A]])(using val run = Machines.foldLayered[F, Coattr[F, A], S](expand, (_, fr) => E.embed(fr)) a => run(Coattr.Pure(a)) - def to(u: Unit): Scheme[X, Unit] = Scheme(()) - def from(b: Scheme[X, A]): S = build(Scheme.value(b)) + def to(u: Unit): Direct[X, Unit] = Direct(()) + def from(b: Direct[X, A]): S = build(Direct.value(b)) - /** 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, the intermediate `S` - * is never built (mirrors [[Ana.cross]] for the trivial indices). Delegates to - * [[Schemes.chrono]], which needs only `Traverse[F]` — the `Embed`/`Project` carried by - * `this`/`histo` go unused. - * - * A member (not the generic `Optic.cross`, which would `reverse.andThen` and materialise) so the - * fused chrono wins overload resolution. + /** 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]): Hylo[A, B] = - Schemes.chrono[F, A, B](coalg, histo.alg)(using F) + def cross[B](histo: Histo[F, S, B]): Chrono[A, B] = Chrono[F, A, B](coalg, histo.alg) - /** The fused mirror-of-dyna seam: multi-layer unfold ∘ node-blind fold ([[Cata]]). The - * refold-quadrant diagonal opposite [[Ana.cross]]'s `dyna`: a free-monad `futu` unfold whose - * fold is a plain `cata`. Fuses — the [[Coattr]] free layers are threaded internally, no - * intermediate `S`. Delegates to [[Schemes.codyna]] (`Traverse[F]` only; the `Cata`'s `Project` - * goes unused). (`codyna` is a descriptive name; the free-unfold/plain-fold refold has no - * standard one.) + /** 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]): Hylo[A, B] = - Schemes.codyna[F, A, B](coalg, cata.alg)(using F) + 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 index e3ca4152..e1200919 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala @@ -4,19 +4,20 @@ package zoo import cats.Traverse +import data.Direct import optics.Optic -/** Histomorphism citizen — a course-of-value fold worn as an optic over [[Scheme]] with **`X = - * Attr[F, A]`**, the cofree comonad `νX. A × F[X]`. This is the thesis at its sharpest: the - * histomorphism's existential is *literally* the universal index for folds. `Cata` is the same - * optic at the forgetful resolution `X = Nothing`; `Histo` keeps the whole decorated history, so - * `Histo : Cata :: Lens : Getter` — the index refined from the trivial comonad up to the cofree - * one. +/** Histomorphism citizen — a course-of-value fold worn as an optic over + * [[dev.constructive.eo.data.Direct]] with **`X = Attr[F, A]`**, the cofree comonad + * `νX. A × F[X]`. This is the thesis at its sharpest: the histomorphism's existential is + * *literally* the universal index for folds. `Cata` is the same optic at the forgetful resolution + * `X = Nothing`; `Histo` keeps the whole decorated history, so `Histo : Cata :: Lens : Getter` — + * the index refined from the trivial comonad up to the cofree one. * * `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), so it can * read arbitrarily far down — folds unreachable by a single-pass [[Cata]]. `Getter`-shaped - * (`Optic[S, Unit, A, Unit, Scheme]`), consumed via `.get`; the read projects the root's head + * (`Optic[S, Unit, A, Unit, Direct]`), consumed via `.get`; the read projects the root's head * ([[Attr.forget]]). * * Space honesty: course-of-value recursion retains O(n) `Attr` cells by nature. Stack-safe (the @@ -25,7 +26,7 @@ import optics.Optic final class Histo[F[_], S, A](private[zoo] val alg: F[Attr[F, A]] => A)(using F: Traverse[F], P: Project[F, S], -) extends Optic[S, Unit, A, Unit, Scheme]: +) extends Optic[S, Unit, A, Unit, Direct]: type X = Attr[F, A] private val toAttr: S => Attr[F, A] = @@ -36,8 +37,8 @@ final class Histo[F[_], S, A](private[zoo] val alg: F[Attr[F, A]] => A)(using private[zoo] val run: S => A = s => Attr.forget(toAttr(s)) - def to(s: S): Scheme[X, A] = Scheme(run(s)) - def from(b: Scheme[X, Unit]): Unit = () + def to(s: S): Direct[X, A] = Direct(run(s)) + def from(b: Direct[X, Unit]): Unit = () /** Metamorphism at the universal indices — the **fold→unfold dual of [[Futu.cross]]**'s chrono * (and the universal-index lift of [[Cata.meta]]). Fold `this` course-of-value to the neck `A`, @@ -48,5 +49,5 @@ final class Histo[F[_], S, A](private[zoo] val alg: F[Attr[F, A]] => A)(using * materialised (the [[Meta.X]] is `A`). The cofree history and free multi-layering live on * either side of that neck, never cancelling across it. */ - def meta[G[_], T](futu: Futu[G, A, T]): Meta[S, A, T] = - new Meta[S, A, T](run.andThen(futu.build)) + 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 index 989d0fde..fe8d7720 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Hylo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Hylo.scala @@ -2,15 +2,35 @@ package dev.constructive.eo package schemes package zoo +import cats.Traverse + +import data.Direct import optics.Optic -/** Hylomorphism citizen — the fused refold worn as an optic over [[Scheme]] with `X = Nothing`. - * `Getter`-shaped (`Optic[Seed, Unit, A, Unit, Scheme]`), consumed via `.get`. Built by - * [[Ana.cross]] or [[Schemes.hylo]]; carries only the fused `refold`, no tree. Not a primitive — - * it *is* `ana.cross(cata)`. +/** Hylomorphism citizen — the **fused** refold worn as an optic over + * [[dev.constructive.eo.data.Direct]] with `X = Nothing`. `Getter`-shaped (`Optic[Seed, Unit, A, + * Unit, Direct]`), consumed via `.get`. 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 runtime 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] val refold: Seed => A) - extends Optic[Seed, Unit, A, Unit, Scheme]: +final class Hylo[Seed, A] private[zoo] (private[zoo] val refold: Seed => A) + extends Optic[Seed, Unit, A, Unit, Direct]: type X = Nothing - def to(s: Seed): Scheme[X, A] = Scheme(refold(s)) - def from(b: Scheme[X, Unit]): Unit = () + def to(s: Seed): Direct[X, A] = Direct(refold(s)) + def from(b: Direct[X, Unit]): Unit = () + +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 index 769baec7..2149bfa7 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala @@ -2,24 +2,44 @@ package dev.constructive.eo package schemes package zoo +import cats.Traverse + +import data.Direct import optics.Optic -/** Metamorphism citizen — a **fold-then-unfold** worn as an optic over [[Scheme]], reading `S => T` - * (`Getter`-shaped, consumed via `.get`). 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. +/** Metamorphism citizen — a **fold-then-unfold** worn as an optic over + * [[dev.constructive.eo.data.Direct]], reading `S => T` (`Getter`-shaped, `.get`). 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 thesis stating the obvious honestly: `meta` **cannot fuse**. It - * folds a functor `F` down to `A`, then unfolds a *different* functor `G` back up; with `F ≠ G` - * there is no `project ∘ embed` cancellation to ride, so `A` is genuinely materialised. (Contrast - * `hylo` / `chrono`, whose single shared functor makes the neck cancel — `X = Nothing`.) Built by - * [[Cata.meta]] / [[Histo.meta]] or [[Schemes.meta]] / [[Schemes.metaChrono]]. + * 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] val run: S => T) extends Optic[S, Unit, T, Unit, Scheme]: +final class Meta[S, A, T] private[zoo] (private[zoo] val run: S => T) + extends Optic[S, Unit, T, Unit, Direct]: type X = A - def to(s: S): Scheme[X, T] = Scheme(run(s)) - def from(b: Scheme[X, Unit]): Unit = () + def to(s: S): Direct[X, T] = Direct(run(s)) + def from(b: Direct[X, Unit]): Unit = () + +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.foldLayered[G, A, T](coalg, (_, gr) => E.embed(gr)) + 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..d13dada4 --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/MetaChrono.scala @@ -0,0 +1,49 @@ +package dev.constructive.eo +package schemes +package zoo + +import cats.Traverse + +import data.Direct +import optics.Optic + +/** Metamorphism at the universal indices — the **fold→unfold dual of [[Chrono]]**, reading `S => T` + * over [[dev.constructive.eo.data.Direct]] (`.get`). 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[zoo] val run: S => T) + extends Optic[S, Unit, T, Unit, Direct]: + type X = A + def to(s: S): Direct[X, T] = Direct(run(s)) + def from(b: Direct[X, Unit]): Unit = () + +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, + (_, layer) => Attr(algebra(layer), layer), + ) + s => Attr.forget(toAttr(s)) + val expand: Coattr[G, A] => G[Coattr[G, A]] = + case Coattr.Pure(a) => coalg(a) + case Coattr.Roll(layer) => layer + val unfold: A => T = + val run = Machines.foldLayered[G, Coattr[G, A], T](expand, (_, gr) => E.embed(gr)) + 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/Para.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala index 620487c7..82f86aab 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala @@ -4,31 +4,33 @@ package zoo import cats.Traverse +import data.Direct import optics.Optic /** Paramorphism citizen — a fold that **retains the original subterms**, worn as an optic over - * [[Scheme]] with **`X = F[(S, A)]`**: each child slot pairs the original subterm `S` with its - * folded result `A`. `Getter`-shaped (`Optic[S, Unit, A, Unit, Scheme]`), consumed via `.get`. + * [[dev.constructive.eo.data.Direct]] with **`X = F[(S, A)]`**: each child slot pairs the original + * subterm `S` with its folded result `A`. `Getter`-shaped (`Optic[S, Unit, A, Unit, Direct]`), + * consumed via `.get`. * * `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 (e.g. "keep the larger of each child's *original* subtree"). - * Ignoring the `S` half degenerates to [[Cata]]. + * subterm itself, not just its summary (e.g. "keep the larger of each child's *original* + * subtree"). 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`). - * The get-put direction 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. + * '''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`). The get-put direction 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. * - * The subterms are recovered by re-`project`ing each node (one extra peel per node) and zipping with - * the children's results in `Foldable` order — sound for any lawful `Traverse`. Stack-safe (the - * [[Machines.foldLayered]] machine). + * The subterms are recovered by re-`project`ing each node (one extra peel per node) and zipping + * with the children's results in `Foldable` order — sound for any lawful `Traverse`. Stack-safe + * (the [[Machines.foldLayered]] machine). */ final class Para[F[_], S, A](private[zoo] val alg: F[(S, A)] => A)(using F: Traverse[F], P: Project[F, S], -) extends Optic[S, Unit, A, Unit, Scheme]: +) extends Optic[S, Unit, A, Unit, Direct]: type X = F[(S, A)] private val run: S => A = @@ -40,5 +42,5 @@ final class Para[F[_], S, A](private[zoo] val alg: F[(S, A)] => A)(using alg(F.map(P.project(s))(sub => (sub, it.next()))), ) - def to(s: S): Scheme[X, A] = Scheme(run(s)) - def from(b: Scheme[X, Unit]): Unit = () + def to(s: S): Direct[X, A] = Direct(run(s)) + def from(b: Direct[X, Unit]): Unit = () diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala index 15ef108a..f1fff408 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala @@ -14,8 +14,8 @@ import schemes.samples.{Bin, BinF} * * `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)`). Over the [[Scheme]] carrier — which keeps the `coalg`/`alg` alive - * — that compose **fuses**: + * `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 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 e261f9bd..393a43ff 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -54,9 +54,9 @@ class SchemesSpec extends Specification: beTrue } - "cata-as-read composes onto an outer Getter via andThen (.readOnly bridges the carrier)" >> { + "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).readOnly) + Getter[(String, Bin), Bin](_._2).andThen(Schemes.cata[BinF, Bin, Int](sumLeaves)) (composed.get(("x", tree)) == 6) 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 index 7c789956..d91e7adc 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala @@ -7,7 +7,7 @@ import org.specs2.mutable.Specification import optics.Optic.* // get, reverseGet -import schemes.samples.{Bin, BinF} +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 @@ -37,7 +37,7 @@ class ZooExtendedSpec extends Specification: "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.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. @@ -65,7 +65,8 @@ class ZooExtendedSpec extends Specification: "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) + 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) } @@ -131,7 +132,8 @@ class ZooExtendedSpec extends Specification: 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 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)) @@ -141,18 +143,62 @@ class ZooExtendedSpec extends Specification: "para and apo are stack/space-safe at depth 10^6" >> { 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 - // 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) - 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.para[BinF, Bin, Int](paraDepth).get(b) == Deep) - .and(Schemes.cata[BinF, Bin, Int](sumLeaves).get(deep) == 0) // all leaves are 0 + // 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 } From d252951bb5eae2391121a387058f4850a51996a7 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 15:50:07 +0200 Subject: [PATCH 39/61] =?UTF-8?q?refactor(schemes):=20dedup=20audit=20?= =?UTF-8?q?=E2=80=94=20centralize=20carrier,=20extract=20expand/decorate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Functionally-equivalent cleanup pass (49/49 unchanged): - ReadScheme / BuildScheme base shapes (SchemeShapes.scala) centralize the Direct carrier wrapping (to/from) into ONE place instead of respelling it in all 14 citizens — so the next carrier change is a 2-file edit, not the 14-file churn the Scheme→Direct migration just paid by hand. Each citizen now supplies only its `read`/`write` fn + its `type X`. (The virtual read/write is one dispatch per O(n) fold — immaterial here, unlike core Getter's hot composed reads; documented.) - Coattr.expand extracts the futu unrolling repeated in Futu/Chrono/Codyna/ MetaChrono (4 copies → 1). - Attr.decorate extracts the cofree-decorating combine repeated in Histo/Dyna/ Chrono/MetaChrono (4 copies → 1). - Deleted dead Machines.rebuildLayerPaired (orphaned when the engine moved rebuildLayer inside foldLayered; para re-projects instead). - Dropped now-unused per-citizen imports (Optic/Direct live in the base); tightened run-field visibility to private where same-class-only. Net -87 lines. Also added two review-gap tests last commit (deep apo graft, variadic-functor para alignment) and bounded the 10^6 para+apo test's peak heap. Co-Authored-By: Claude Opus 4.8 --- .../constructive/eo/schemes/Machines.scala | 15 ------ .../dev/constructive/eo/schemes/zoo/Ana.scala | 18 +++---- .../dev/constructive/eo/schemes/zoo/Apo.scala | 21 ++++----- .../constructive/eo/schemes/zoo/Attr.scala | 7 +++ .../constructive/eo/schemes/zoo/Cata.scala | 34 +++++--------- .../constructive/eo/schemes/zoo/Chrono.scala | 31 ++++-------- .../constructive/eo/schemes/zoo/Coattr.scala | 10 ++++ .../constructive/eo/schemes/zoo/Codyna.scala | 25 ++++------ .../constructive/eo/schemes/zoo/Coelgot.scala | 23 ++++----- .../constructive/eo/schemes/zoo/Dyna.scala | 23 ++++----- .../constructive/eo/schemes/zoo/Elgot.scala | 20 +++----- .../constructive/eo/schemes/zoo/Futu.scala | 27 ++++------- .../constructive/eo/schemes/zoo/Histo.scala | 47 ++++++------------- .../constructive/eo/schemes/zoo/Hylo.scala | 18 +++---- .../constructive/eo/schemes/zoo/Meta.scala | 18 +++---- .../eo/schemes/zoo/MetaChrono.scala | 28 ++++------- .../constructive/eo/schemes/zoo/Para.scala | 31 +++++------- .../eo/schemes/zoo/SchemeShapes.scala | 29 ++++++++++++ 18 files changed, 169 insertions(+), 256 deletions(-) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/SchemeShapes.scala diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala index 7e839731..7bc9f3a6 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -169,21 +169,6 @@ private[schemes] object Machines: resultAt(out(i)) } - /** [[rebuildLayer]]'s paramorphic sibling: pair each original child `N` with its folded result - * from `out` (positional, `Foldable` order). The subterms come from the layer the machine - * already holds — no re-`embed`. - */ - private[schemes] 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))) - } - /** 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 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 index 39647f18..fb281377 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala @@ -4,27 +4,21 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Anamorphism citizen — an unfold worn as an optic over [[dev.constructive.eo.data.Direct]] with - * `X = S` (the structure it threads). `Review`-shaped (`Optic[Unit, S, Unit, Seed, Direct]`): the - * build `from` runs the unfold; the read side is vestigial. 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 — see [[Futu]]). +/** 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 Optic[Unit, S, Unit, Seed, Direct]: +) extends BuildScheme[S, Seed]: type X = S private[zoo] val build: Seed => S = Machines.foldLayered[F, Seed, S](coalg, (_, fr) => E.embed(fr)) - def to(u: Unit): Direct[X, Unit] = Direct(()) - def from(b: Direct[X, Seed]): S = build(Direct.value(b)) + 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`, 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 index c1131ef5..998c86dd 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala @@ -4,27 +4,23 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Apomorphism citizen — an unfold that may **short-circuit with a finished subtree**, worn as an - * optic over [[dev.constructive.eo.data.Direct]] 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. `Review`-shaped (`Optic[Unit, S, Unit, A, Direct]`), consumed via - * `.reverseGet`. +/** 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]]. * * '''O(1) graft.''' A `Left(s)` subtree is placed into its result slot **by reference** — - * [[Machines.foldLayeredOr]]'s `Left` arm returns it without recursing or re-`project`ing — so - * grafting a finished subtree is constant-time, not O(subtree). Stack-safe. + * [[Machines.foldLayeredOr]]'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 Optic[Unit, S, Unit, A, Direct]: +) extends BuildScheme[S, A]: type X = Either[S, A] private val build: A => S = @@ -37,5 +33,4 @@ final class Apo[F[_], A, S](private[zoo] val coalg: A => F[Either[S, A]])(using ) a => run(Right(a)) - def to(u: Unit): Direct[X, Unit] = Direct(()) - def from(b: Direct[X, A]): S = build(Direct.value(b)) + protected def write(a: A): S = build(a) 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 index d1176dc3..3792b556 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Attr.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Attr.scala @@ -17,3 +17,10 @@ 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) 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 index b6c4666a..31a1852b 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cata.scala @@ -4,40 +4,28 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Catamorphism citizen — a **node-blind** fold worn as an optic over - * [[dev.constructive.eo.data.Direct]] with `X = Nothing`, the forgetful (trivial) resolution of - * the recursion index. `Getter`-shaped (`Optic[S, Unit, A, Unit, Direct]`): the read `to` runs the - * fold; the build side is vestigial. Carries `alg` so [[Ana.cross]] can rebuild the fused [[Hylo]] - * machine. +/** 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, a lawful Lens) and [[Attr]] = - * `νX. A × F[X]` (histomorphism, the cofree comonad — see [[Histo]]). + * `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 Optic[S, Unit, A, Unit, Direct]: +) extends ReadScheme[S, A]: type X = Nothing - private[zoo] val run: S => A = - Machines.foldLayered[F, S, A](P.project, (_, fr) => alg(fr)) - - def to(s: S): Direct[X, A] = Direct(run(s)) - def from(b: Direct[X, Unit]): Unit = () + 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 value `A`, then unfold it with `ana` into a fresh `G`-recursive `T`. The result - * [[Meta]] reads `S => T` (`.get`). - * - * Unlike [[Ana.cross]] this **does not fuse**: the fold is over `F` and the unfold over a - * possibly-different `G`, so there is no `project ∘ embed` cancellation — the neck `A` is - * genuinely materialised (the [[Meta.X]] is `A`, not `Nothing`). That heterogeneity is exactly - * why `meta` keeps both `Basis`es while `hylo` drops them. + * 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 index f8b1015c..90445720 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Chrono.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Chrono.scala @@ -4,38 +4,27 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Chronomorphism citizen — the **fused** futu-then-histo refold, [[Hylo]] lifted to the universal - * indices: unfold through the free monad ([[Coattr]]), fold through the cofree comonad ([[Attr]]), - * building no intermediate `S`. `Getter`-shaped over [[dev.constructive.eo.data.Direct]], consumed - * via `.get`. Built by [[Futu.cross]] or [[Chrono.apply]]. - * - * A nominally-distinct member of the fused-refold family (see [[Hylo]]): honest `X = Nothing`, since - * fusion discards the `Coattr`/`Attr` it threads internally. +/** 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[zoo] val refold: A => B) - extends Optic[A, Unit, B, Unit, Direct]: +final class Chrono[A, B] private[zoo] (private val refold: A => B) extends ReadScheme[A, B]: type X = Nothing - def to(a: A): Direct[X, B] = Direct(refold(a)) - def from(b: Direct[X, Unit]): Unit = () + protected def read(a: A): B = refold(a) object Chrono: - /** The fused free→cofree refold `A => B`, needing only `Traverse[F]`. Heads-only `algebra` + - * all-`Pure` `coalg` degenerate to [[Hylo]]. Stack-safe; retains O(n) `Attr` cells by nature. + /** 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 expand: Coattr[F, A] => F[Coattr[F, A]] = - case Coattr.Pure(a) => coalg(a) - case Coattr.Roll(layer) => layer val build: Coattr[F, A] => Attr[F, B] = Machines.foldLayered[F, Coattr[F, A], Attr[F, B]]( - expand, - (_, layer) => Attr(algebra(layer), layer), + 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 index f0269dbf..a87565cf 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coattr.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coattr.scala @@ -18,3 +18,13 @@ enum Coattr[F[_], 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 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 index 9b42ea04..af244af2 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Codyna.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Codyna.scala @@ -4,21 +4,15 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** The fused multi-layer-unfold → node-blind-fold refold — the mirror of [[Dyna]], opposite diagonal - * of the refold quadrant: a free-monad `futu` unfold ([[Coattr]]) whose fold is a plain `cata`, built - * with no intermediate `S`. `Getter`-shaped over [[dev.constructive.eo.data.Direct]], `.get`. 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.) +/** 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[zoo] val refold: A => B) - extends Optic[A, Unit, B, Unit, Direct]: +final class Codyna[A, B] private[zoo] (private val refold: A => B) extends ReadScheme[A, B]: type X = Nothing - def to(a: A): Direct[X, B] = Direct(refold(a)) - def from(b: Direct[X, Unit]): Unit = () + protected def read(a: A): B = refold(a) object Codyna: @@ -28,8 +22,5 @@ object Codyna: def apply[F[_], A, B](coalg: A => F[Coattr[F, A]], alg: F[B] => B)(using F: Traverse[F] ): Codyna[A, B] = - val expand: Coattr[F, A] => F[Coattr[F, A]] = - case Coattr.Pure(a) => coalg(a) - case Coattr.Roll(layer) => layer - val run = Machines.foldLayered[F, Coattr[F, A], B](expand, (_, fr) => alg(fr)) + 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 index 2af76d97..6edf5cfe 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coelgot.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coelgot.scala @@ -4,25 +4,20 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Co-Elgot citizen — a [[Hylo]] whose **fold may read the seed**: `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). Fused `A => B`, `Traverse[F]` only. `Getter`-shaped over - * [[dev.constructive.eo.data.Direct]], `.get`. Built by [[Coelgot.apply]]. Nominally distinct in the - * fused-refold family (see [[Hylo]]): honest `X = Nothing`. +/** 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[zoo] val refold: A => B) - extends Optic[A, Unit, B, Unit, Direct]: +final class Coelgot[A, B] private[zoo] (private val refold: A => B) extends ReadScheme[A, B]: type X = Nothing - def to(a: A): Direct[X, B] = Direct(refold(a)) - def from(b: Direct[X, Unit]): Unit = () + 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. + /** 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] 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 index 95cda4d7..6d64c84e 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Dyna.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Dyna.scala @@ -4,21 +4,15 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Dynamorphism citizen — the **fused** plain-unfold → course-of-value-fold refold, the - * refold-quadrant diagonal between [[Hylo]] (plain→plain) and [[Chrono]] (free→cofree). A plain - * `ana` unfold whose fold sees each node's full decorated history ([[Attr]], the cofree memo), built - * with no intermediate `S`. `Getter`-shaped over [[dev.constructive.eo.data.Direct]], `.get`. Built - * by [[Ana.cross]] or [[Dyna.apply]]. Nominally distinct in the fused-refold family (see [[Hylo]]): - * honest `X = Nothing`. +/** 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[zoo] val refold: A => B) - extends Optic[A, Unit, B, Unit, Direct]: +final class Dyna[A, B] private[zoo] (private val refold: A => B) extends ReadScheme[A, B]: type X = Nothing - def to(a: A): Direct[X, B] = Direct(refold(a)) - def from(b: Direct[X, Unit]): Unit = () + protected def read(a: A): B = refold(a) object Dyna: @@ -28,6 +22,5 @@ object Dyna: 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, (_, layer) => Attr(alg(layer), layer)) + 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 index f535bea2..d6d3f6aa 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Elgot.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Elgot.scala @@ -4,21 +4,15 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Elgot citizen — a [[Hylo]] whose **unfold may short-circuit**: `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. Fused refold `A => B` (no intermediate `S`), driven by the - * short-circuit-aware [[Machines.foldLayeredOr]]. `Getter`-shaped over - * [[dev.constructive.eo.data.Direct]], `.get`. Built by [[Elgot.apply]]. Nominally distinct in the - * fused-refold family (see [[Hylo]]): honest `X = Nothing`. +/** 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[zoo] val refold: A => B) - extends Optic[A, Unit, B, Unit, Direct]: +final class Elgot[A, B] private[zoo] (private val refold: A => B) extends ReadScheme[A, B]: type X = Nothing - def to(a: A): Direct[X, B] = Direct(refold(a)) - def from(b: Direct[X, Unit]): Unit = () + protected def read(a: A): B = refold(a) object Elgot: 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 index b349a223..6c24f3e3 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala @@ -4,33 +4,24 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Futumorphism citizen — a multi-layer unfold worn as an optic over - * [[dev.constructive.eo.data.Direct]] 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. `Review`-shaped (`Optic[Unit, S, Unit, A, Direct]`), consumed - * via `.reverseGet`; 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. +/** 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 Optic[Unit, S, Unit, A, Direct]: +) extends BuildScheme[S, A]: type X = Coattr[F, A] private[zoo] val build: A => S = - val expand: Coattr[F, A] => F[Coattr[F, A]] = - case Coattr.Pure(a) => coalg(a) - case Coattr.Roll(layer) => layer - val run = Machines.foldLayered[F, Coattr[F, A], S](expand, (_, fr) => E.embed(fr)) + val run = Machines.foldLayered[F, Coattr[F, A], S](Coattr.expand(coalg), (_, fr) => E.embed(fr)) a => run(Coattr.Pure(a)) - def to(u: Unit): Direct[X, Unit] = Direct(()) - def from(b: Direct[X, A]): S = build(Direct.value(b)) + 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 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 index e1200919..32ed90f5 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Histo.scala @@ -4,50 +4,31 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Histomorphism citizen — a course-of-value fold worn as an optic over - * [[dev.constructive.eo.data.Direct]] with **`X = Attr[F, A]`**, the cofree comonad - * `νX. A × F[X]`. This is the thesis at its sharpest: the histomorphism's existential is - * *literally* the universal index for folds. `Cata` is the same optic at the forgetful resolution - * `X = Nothing`; `Histo` keeps the whole decorated history, so `Histo : Cata :: Lens : Getter` — - * the index refined from the trivial comonad up to the cofree one. +/** 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), so it can - * read arbitrarily far down — folds unreachable by a single-pass [[Cata]]. `Getter`-shaped - * (`Optic[S, Unit, A, Unit, Direct]`), consumed via `.get`; the read projects the root's head - * ([[Attr.forget]]). - * - * Space honesty: course-of-value recursion retains O(n) `Attr` cells by nature. Stack-safe (the - * [[Machines.foldLayered]] machine). Heads-only (`alg ∘ map(_.head)`) degenerates to [[Cata]]. + * 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 Optic[S, Unit, A, Unit, Direct]: +) 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, - (_, layer) => Attr(alg(layer), layer), - ) - - private[zoo] val run: S => A = s => Attr.forget(toAttr(s)) + Machines.foldLayered[F, S, Attr[F, A]](P.project, Attr.decorate(alg)) - def to(s: S): Direct[X, A] = Direct(run(s)) - def from(b: Direct[X, Unit]): Unit = () + 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 - * (and the universal-index lift of [[Cata.meta]]). Fold `this` course-of-value to the neck `A`, - * then multi-layer-unfold it with `futu` into a fresh `G`-recursive `T`. Reads `S => T` - * (`.get`). - * - * Like [[Cata.meta]] this **does not fuse** — `F` and `G` differ, so the neck `A` is - * materialised (the [[Meta.X]] is `A`). The cofree history and free multi-layering live on - * either side of that neck, never cancelling across it. + /** 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 index fe8d7720..d9a8804c 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Hylo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Hylo.scala @@ -4,25 +4,19 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Hylomorphism citizen — the **fused** refold worn as an optic over - * [[dev.constructive.eo.data.Direct]] with `X = Nothing`. `Getter`-shaped (`Optic[Seed, Unit, A, - * Unit, Direct]`), consumed via `.get`. Built by [[Ana.cross]] or [[Hylo.apply]]; carries only the - * fused `refold`, no tree. Not a primitive — it *is* `ana.cross(cata)`. +/** 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 runtime shape — a `refold: Seed => A` with a vestigial build side, so + * [[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[zoo] val refold: Seed => A) - extends Optic[Seed, Unit, A, Unit, Direct]: +final class Hylo[Seed, A] private[zoo] (private val refold: Seed => A) extends ReadScheme[Seed, A]: type X = Nothing - def to(s: Seed): Direct[X, A] = Direct(refold(s)) - def from(b: Direct[X, Unit]): Unit = () + protected def read(s: Seed): A = refold(s) object Hylo: 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 index 2149bfa7..16b3e671 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala @@ -4,14 +4,10 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Metamorphism citizen — a **fold-then-unfold** worn as an optic over - * [[dev.constructive.eo.data.Direct]], reading `S => T` (`Getter`-shaped, `.get`). 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. +/** 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` @@ -21,11 +17,9 @@ import optics.Optic * @tparam A * the neck — the retained intermediate value type (the optic's existential `X`) */ -final class Meta[S, A, T] private[zoo] (private[zoo] val run: S => T) - extends Optic[S, Unit, T, Unit, Direct]: +final class Meta[S, A, T] private[zoo] (private val run: S => T) extends ReadScheme[S, T]: type X = A - def to(s: S): Direct[X, T] = Direct(run(s)) - def from(b: Direct[X, Unit]): Unit = () + protected def read(s: S): T = run(s) object Meta: 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 index d13dada4..f77f6d9a 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/MetaChrono.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/MetaChrono.scala @@ -4,13 +4,10 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Metamorphism at the universal indices — the **fold→unfold dual of [[Chrono]]**, reading `S => T` - * over [[dev.constructive.eo.data.Direct]] (`.get`). 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]]. +/** 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 — @@ -19,11 +16,9 @@ import optics.Optic * @tparam A * the neck — the retained intermediate value type (the optic's existential `X`) */ -final class MetaChrono[S, A, T] private[zoo] (private[zoo] val run: S => T) - extends Optic[S, Unit, T, Unit, Direct]: +final class MetaChrono[S, A, T] private[zoo] (private val run: S => T) extends ReadScheme[S, T]: type X = A - def to(s: S): Direct[X, T] = Direct(run(s)) - def from(b: Direct[X, Unit]): Unit = () + protected def read(s: S): T = run(s) object MetaChrono: @@ -35,15 +30,10 @@ object MetaChrono: 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, - (_, layer) => Attr(algebra(layer), layer), - ) + val toAttr = Machines.foldLayered[F, S, Attr[F, A]](P.project, Attr.decorate(algebra)) s => Attr.forget(toAttr(s)) - val expand: Coattr[G, A] => G[Coattr[G, A]] = - case Coattr.Pure(a) => coalg(a) - case Coattr.Roll(layer) => layer val unfold: A => T = - val run = Machines.foldLayered[G, Coattr[G, A], T](expand, (_, gr) => E.embed(gr)) + val run = + Machines.foldLayered[G, Coattr[G, A], T](Coattr.expand(coalg), (_, gr) => E.embed(gr)) 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/Para.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala index 82f86aab..e06c2962 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala @@ -4,33 +4,27 @@ package zoo import cats.Traverse -import data.Direct -import optics.Optic - -/** Paramorphism citizen — a fold that **retains the original subterms**, worn as an optic over - * [[dev.constructive.eo.data.Direct]] with **`X = F[(S, A)]`**: each child slot pairs the original - * subterm `S` with its folded result `A`. `Getter`-shaped (`Optic[S, Unit, A, Unit, Direct]`), - * consumed via `.get`. +/** 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 (e.g. "keep the larger of each child's *original* - * subtree"). Ignoring the `S` half degenerates to [[Cata]]. + * 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`). The get-put direction 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. + * 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. * - * The subterms are recovered by re-`project`ing each node (one extra peel per node) and zipping - * with the children's results in `Foldable` order — sound for any lawful `Traverse`. Stack-safe - * (the [[Machines.foldLayered]] machine). + * Subterms are recovered by re-`project`ing each node (one extra peel per node) and zipping with + * the children's results in `Foldable` order — sound for any lawful `Traverse`. Stack-safe (the + * [[Machines.foldLayered]] machine). */ final class Para[F[_], S, A](private[zoo] val alg: F[(S, A)] => A)(using F: Traverse[F], P: Project[F, S], -) extends Optic[S, Unit, A, Unit, Direct]: +) extends ReadScheme[S, A]: type X = F[(S, A)] private val run: S => A = @@ -42,5 +36,4 @@ final class Para[F[_], S, A](private[zoo] val alg: F[(S, A)] => A)(using alg(F.map(P.project(s))(sub => (sub, it.next()))), ) - def to(s: S): Direct[X, A] = Direct(run(s)) - def from(b: Direct[X, Unit]): Unit = () + 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..83badc9f --- /dev/null +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/SchemeShapes.scala @@ -0,0 +1,29 @@ +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)) From 1876c294290ce89d2eec92bc09546d86d81370ea Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 16:23:04 +0200 Subject: [PATCH 40/61] =?UTF-8?q?feat(schemes):=20zygo/mutu/cozygo/comutu?= =?UTF-8?q?=20+=20prepro/postpro=20=E2=80=94=20close=20the=20zoo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the recursion-scheme zoo's two index towers and add the orthogonal natural-transformation axis: - zygo / mutu: the comonad-tower auxiliary rung (X = F[(B,A)] / F[(A,B)]); para is zygo at B=S, aux=embed. mutu generalises zygo (mutual recursion). - cozygo / comutu: the build-side g-apo duals (X = Either[B,A] / Either[A,B]). - prepro / postpro: keep the trivial index, decorate the layer optic with an accumulating natural transformation η : F ~> F (O(n·depth); η = id recovers cata/ana). The axis orthogonal to the (co)monad towers. ghylo deliberately omitted — the v2 biaffine-zoo plan rejected free-range generic g-schemes. All wired through Schemes.* factories reusing the shared foldLayered engine; ZooTowersSpec pins each scheme's degeneracy-to-base law + a real-behaviour case + stack-safety. schemes/test now forks with -Xss8m: the 10^6 stack-safety specs take a bounded 512-frame on-stack prefix before the heap walk, which overflows specs2's small-stack parallel pool threads under the heavier suite. Fork keeps the realistic 10^6 tests AND parallel execution rather than shrinking either. Co-Authored-By: Claude Opus 4.8 (1M context) --- build.sbt | 11 + .../dev/constructive/eo/schemes/Schemes.scala | 94 +++++++- .../constructive/eo/schemes/zoo/Comutu.scala | 31 +++ .../constructive/eo/schemes/zoo/Cozygo.scala | 35 +++ .../constructive/eo/schemes/zoo/Mutu.scala | 28 +++ .../constructive/eo/schemes/zoo/Postpro.scala | 34 +++ .../constructive/eo/schemes/zoo/Prepro.scala | 36 +++ .../eo/schemes/zoo/SchemeShapes.scala | 17 +- .../constructive/eo/schemes/zoo/Zygo.scala | 39 ++++ .../eo/schemes/ZooTowersSpec.scala | 213 ++++++++++++++++++ 10 files changed, 522 insertions(+), 16 deletions(-) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Comutu.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cozygo.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Mutu.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Postpro.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Prepro.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Zygo.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala diff --git a/build.sbt b/build.sbt index b07009b0..7897c74f 100644 --- a/build.sbt +++ b/build.sbt @@ -675,6 +675,17 @@ lazy val schemes: Project = project libraryDependencies += cats, libraryDependencies += discipline % Test, libraryDependencies += scalacheck % 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", ) // Discipline-style laws for the recursion-scheme module. Lives outside 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 85f82b13..7e96cd1a 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -1,7 +1,7 @@ package dev.constructive.eo package schemes -import cats.Traverse +import cats.{~>, Traverse} import data.{Forget, ForgetK} import optics.Optic @@ -16,20 +16,32 @@ import zoo.* * 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 | - * | [[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 | - * | [[apo]] | `Either[S, A]` | the **Prism** residual (graft, build-side) | - * | [[futu]] | [[zoo.Coattr]] = `μX. A + F[X]` | the **free monad** (multi-layer unfold) | + * | 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]`). + * + * 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`). + * * ==hylo is the fusion, not a primitive — and meta is the honest non-fusion== * * [[ana]] is a build (`Review`-shaped) and [[cata]] a node-blind fold (`Getter`-shaped); the @@ -92,6 +104,26 @@ object Schemes: 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`. */ @@ -111,6 +143,26 @@ object Schemes: 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 @@ -178,3 +230,27 @@ object Schemes: 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) 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..57e67eef --- /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.foldLayered[F, Either[A, B], S](expand, (_, fr) => E.embed(fr)) + 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..50f72464 --- /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.foldLayered[F, Either[B, A], S](expand, (_, fr) => E.embed(fr)) + a => run(Right(a)) + + protected def write(a: A): S = build(a) 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/Postpro.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Postpro.scala new file mode 100644 index 00000000..1de9b67f --- /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 index 83badc9f..c72f0def 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/SchemeShapes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/SchemeShapes.scala @@ -6,14 +6,15 @@ 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 + * [[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). + * 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`). */ @@ -22,7 +23,9 @@ abstract class ReadScheme[S, A] extends Optic[S, Unit, A, Unit, Direct]: 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`). */ +/** 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](()) 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/ZooTowersSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala new file mode 100644 index 00000000..c1f1da89 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala @@ -0,0 +1,213 @@ +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 + } From 6ab378a441e16fd67e3c29f43a1b8fc4da59071b Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 16:32:10 +0200 Subject: [PATCH 41/61] =?UTF-8?q?feat(schemes):=20monadic=20*M=20family=20?= =?UTF-8?q?=E2=80=94=20cataM/anaM/hyloM=20&=20co=20on=20the=20latent=20eng?= =?UTF-8?q?ine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The M-class needed no engine work: Machines.foldLayeredM (the Monad[M]-lifted walk, tailRecM-driven, stack-safe, Or-shaped for graft/short-circuit) already existed but had zero callers. This adds only the public surface, with no code explosion: - Two function-wrapping citizens carry the WHOLE M-zoo: FoldM (read side: cataM/paraM/histoM/hyloM/chronoM) and BuildM (build side: anaM/apoM/futuM). The recursion index isn't erased by the consolidation — it rides each citizen's phantom type param XI (cataM = FoldM[..., Nothing], paraM = FoldM[..., F[(S,A)]], ...), so the X-as-index thesis holds per factory. - The pure layer decorations (Attr.decorate, Coattr.expand, the para zip, the apo Either) compose with M for free, so each *M factory is a one-liner over foldLayeredM. - Contract documented on the family: M must be single-pass / linear / sequential (Id, Eval, State, IO) — a branching/replaying M corrupts the engine's mutable walk state. ghylo-style free-range generics stay rejected (v2 biaffine plan); the *M schemes are named, consistent with the rest of the zoo. SchemesMSpec pins each scheme's M=Id agreement with its pure twin, Option short-circuiting (a real effect the pure scheme can't express), and empirical Eval stack-safety folding/building 10^6-deep. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/constructive/eo/schemes/Schemes.scala | 129 +++++++++++++- .../constructive/eo/schemes/zoo/BuildM.scala | 32 ++++ .../constructive/eo/schemes/zoo/Cozygo.scala | 4 +- .../constructive/eo/schemes/zoo/FoldM.scala | 37 ++++ .../constructive/eo/schemes/zoo/Postpro.scala | 6 +- .../eo/schemes/SchemesMSpec.scala | 159 ++++++++++++++++++ .../eo/schemes/ZooTowersSpec.scala | 16 +- 7 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/BuildM.scala create mode 100644 schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala 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 7e96cd1a..1413dba6 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -1,7 +1,7 @@ package dev.constructive.eo package schemes -import cats.{~>, Traverse} +import cats.{~>, Monad, Traverse} import data.{Forget, ForgetK} import optics.Optic @@ -254,3 +254,130 @@ object Schemes: 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`), and the pure + // layer decorations ([[zoo.Attr.decorate]] / [[zoo.Coattr.expand]] / the para zip / the apo + // residual) compose with `M` for free. `M` must be single-pass / linear / sequential (`Id`, + // `Eval`, `State`, `IO`); a branching/replaying `M` corrupts the engine's mutable walk state. + + /** 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](s => M.pure(Right(P.project(s))), (_, 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]( + s => M.pure(Right(P.project(s))), + (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]]( + s => M.pure(Right(P.project(s))), + (_, layer) => M.map(alg(layer))(a => Attr(a, layer)), + ) + 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]( + seed => M.map(coalg(seed))(Right(_)), + (_, fr) => M.pure(E.embed(fr)), + ) + ) + + /** 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(_)) + }, + (_, fr) => M.pure(E.embed(fr)), + ) + 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]( + { + case Coattr.Pure(a) => M.map(coalg(a))(Right(_)) + case Coattr.Roll(layer) => M.pure(Right(layer)) + }, + (_, fr) => M.pure(E.embed(fr)), + ) + 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](seed => M.map(coalg(seed))(Right(_)), (_, 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]]( + { + case Coattr.Pure(a) => M.map(coalg(a))(Right(_)) + case Coattr.Roll(layer) => M.pure(Right(layer)) + }, + (_, layer) => M.map(alg(layer))(b => Attr(b, layer)), + ) + FoldM(a => M.map(build(Coattr.Pure(a)))(Attr.forget)) 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..bf032963 --- /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/Cozygo.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cozygo.scala index 50f72464..e3200a2c 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cozygo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cozygo.scala @@ -5,8 +5,8 @@ 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. + * [[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 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..840fb6b0 --- /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/Postpro.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Postpro.scala index 1de9b67f..46ca5a31 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Postpro.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Postpro.scala @@ -5,9 +5,9 @@ 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. + * 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 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..2b7359b1 --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala @@ -0,0 +1,159 @@ +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/ZooTowersSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala index c1f1da89..b99d43dd 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala @@ -66,7 +66,7 @@ class ZooTowersSpec extends Specification: 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.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) @@ -74,7 +74,7 @@ class ZooTowersSpec extends Specification: "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.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 @@ -83,7 +83,7 @@ class ZooTowersSpec extends Specification: "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.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 } @@ -93,19 +93,19 @@ class ZooTowersSpec extends Specification: "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.LeafF(_) => 1 case BinF.BranchF((_, b1), (_, b2)) => 1 + b1 + b2 val algA: BinF[(Int, Int)] => Int = - case BinF.LeafF(n) => n + 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.LeafF(n) => n case BinF.BranchF((sizeL, resL), (_, resR)) => resL + resR + sizeL } .get(tree) - viaMutu === viaZygo and (viaMutu === 15) + (viaMutu === viaZygo).and(viaMutu === 15) } "mutu is genuinely mutual: each algebra reads the other's half" >> { @@ -114,7 +114,7 @@ class ZooTowersSpec extends Specification: 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.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. From c6cfb8afab0b3d336749351a645d039c768809be Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 17:07:52 +0200 Subject: [PATCH 42/61] =?UTF-8?q?feat(core):=20move=20Basis=20into=20core,?= =?UTF-8?q?=20derive=20Plated=20from=20it=20=E2=80=94=20the=20schemes?= =?UTF-8?q?=E2=86=94Plated=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project/Embed/Basis (the S↔F pattern-functor correspondence) move from the schemes module into core (optics package), so core's Plated can derive from them. A top-level `export optics.{Basis, Embed, Project}` in the schemes package keeps every scheme citizen referring to them unqualified — no per-file churn. New: Plated.fromBasis[F, S] (using Traverse[F], Project[F,S], Embed[F,S]) — the same Basis that drives cata/ana now also drives core's Plated recursion combinators (children/universe/transform/rewrite) and the MultiFocus[PSVec] carrier. A scheme's single layer IS this self-traversal; Plated is its non-recursive face. Children are built copy-free via ObjArrBuilder + PSVec.unsafeWrap (fresh per call, honouring fromChildrenVec's contract). PlatedBridgeSpec pins it end-to-end: one Basis[BinF, Bin] feeds both Plated.children/universe/transform AND Schemes.cata, and they agree. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/constructive/eo/optics/Basis.scala | 80 +++++++++++++++++++ .../dev/constructive/eo/optics/Plated.scala | 16 ++++ .../dev/constructive/eo/schemes/Basis.scala | 56 ++----------- .../constructive/eo/schemes/zoo/BuildM.scala | 8 +- .../constructive/eo/schemes/zoo/FoldM.scala | 10 +-- .../eo/schemes/PlatedBridgeSpec.scala | 59 ++++++++++++++ .../eo/schemes/SchemesMSpec.scala | 14 ++-- 7 files changed, 177 insertions(+), 66 deletions(-) create mode 100644 core/src/main/scala/dev/constructive/eo/optics/Basis.scala create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/PlatedBridgeSpec.scala 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/Plated.scala b/core/src/main/scala/dev/constructive/eo/optics/Plated.scala index 75da85b6..e9d59ce4 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Plated.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Plated.scala @@ -3,6 +3,8 @@ package optics import scala.annotation.tailrec +import cats.{Eval, Traverse} + import cats.Eval import java.util.ArrayDeque @@ -100,6 +102,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/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala index becd2046..bad0941d 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Basis.scala @@ -1,54 +1,10 @@ package dev.constructive.eo package schemes -/** The user-supplied bridge between a recursive type `S` and one *layer* of its **pattern functor** - * `F[_]`, for the typed recursion-scheme path ([[Schemes.cata]] / [[Schemes.ana]] / - * [[Schemes.hylo]]). - * - * 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`). - * The 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 `PSVec[AnyRef]` — - * the type-safety the default [[Schemes.cata]] path lacks. - * - * Unlike the `F` type itself (which the user must write — Scala-3 macros emit terms, not type - * definitions), `Project`/`Embed` are ordinary instances. v1 expects them **hand-written** - * (droste's model); deriving them from the `S`↔`F` constructor correspondence is deferred future - * work. - * - * '''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 - * }}} +/** 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. */ -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) +export optics.{Basis, Embed, Project} 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 index bf032963..1424a67c 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/BuildM.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/BuildM.scala @@ -7,10 +7,10 @@ package zoo * 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. + * [[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]]. 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 index 840fb6b0..19a85bfd 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/FoldM.scala @@ -3,8 +3,8 @@ 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]`). + * 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 @@ -29,9 +29,9 @@ final class FoldM[S, A, M[_], XI] private[zoo] (run: S => M[A]) extends ReadSche 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. + /** 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/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..52fc2e3e --- /dev/null +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/PlatedBridgeSpec.scala @@ -0,0 +1,59 @@ +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 index 2b7359b1..6cbea840 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala @@ -62,15 +62,15 @@ class SchemesMSpec extends Specification: 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) + (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.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) @@ -97,9 +97,9 @@ class SchemesMSpec extends Specification: "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) + (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)" >> { @@ -130,7 +130,7 @@ class SchemesMSpec extends Specification: 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) + idOk.and(Schemes.hyloM[Option, BinF, Int, Int](coalgOpt, algOpt).get(6) must beSome) } "chronoM at M = Id reproduces chrono" >> { From c2d3180385b786ed128ea3078e727f343fc04636 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 17:11:28 +0200 Subject: [PATCH 43/61] =?UTF-8?q?feat(schemes):=20fLayer=20rides=20MultiFo?= =?UTF-8?q?cus[F]=20=E2=80=94=20a=20typed=20single-layer=20self-traversal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fLayer was a read-only Forget[F] optic. Re-carrier it on core's MultiFocus[F][X, A] = (X, F[A]) — the Traversal/AlgLens/Grate carrier — with to(s) = ((), project(s)), from((_, fs)) = embed(fs), X = Unit (the F-shape rides inside the foci, so embed needs no extra leftover). Now that the layer optic shares the carrier of Plated.plate and Traversal.each, it composes with the rest of core and gains the write half for free: .foldMap (read foci), .modify / .replace (rewrite immediate children), .modifyA / .all (effectful) — where the Forget spelling was read-only. fLayer is one layer; Plated.fromBasis (Step 1) is its recursive face. SchemesSpec's fLayer cases move to the MultiFocus carrier and add a .modify case proving the read+write upgrade. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/constructive/eo/schemes/Schemes.scala | 35 +++++++++++-------- .../eo/schemes/PlatedBridgeSpec.scala | 5 +-- .../constructive/eo/schemes/SchemesSpec.scala | 17 ++++++--- 3 files changed, 36 insertions(+), 21 deletions(-) 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 1413dba6..a55f56b7 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -3,7 +3,7 @@ package schemes import cats.{~>, Monad, Traverse} -import data.{Forget, ForgetK} +import data.MultiFocus import optics.Optic import zoo.* @@ -68,23 +68,30 @@ import zoo.* */ object Schemes: - /** The single *layer* optic for a pattern functor `F`: `project`/`embed` worn as the existing - * [[dev.constructive.eo.data.Forget]] carrier. `to = project: S => F[S]`, `from = embed: F[S] => - * S`, so it is a genuine `Optic[S, S, S, S, Forget[F]]` with **no change to the `Optic` trait** - * — the concrete proof that a typed `F` is an optic carrier, and an observational read (given - * `Foldable[F]`) of a layer's immediate foci via `.foldMap`. It is a single-layer peel/glue - * (like `Plated`'s `plate`, but one layer, not the recursion); the schemes drive `to`/`from` - * themselves. + /** 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]`. + * + * 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, Forget[F]] = + 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 optic. */ + /** 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, Forget[F]]: - type X = Any - def to(s: S): Forget[F][X, S] = ForgetK(P.project(s)) - def from(fs: Forget[F][X, S]): S = E.embed(fs.value) + 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 =============================================================================== diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/PlatedBridgeSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/PlatedBridgeSpec.scala index 52fc2e3e..027dfc96 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/PlatedBridgeSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/PlatedBridgeSpec.scala @@ -9,7 +9,8 @@ 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. */ + * correspondence, both worlds. + */ class PlatedBridgeSpec extends Specification: // The bridge: derive the core Plated straight from the schemes' Basis. @@ -55,5 +56,5 @@ class PlatedBridgeSpec extends Specification: case BinF.BranchF(l, r) => l + r } .get(tree) - viaPlated === viaCata and (viaPlated === 6) + (viaPlated === viaCata).and(viaPlated === 6) } 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 393a43ff..1d76affe 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -5,9 +5,9 @@ import scala.language.implicitConversions import org.specs2.mutable.Specification -import data.Forget +import data.MultiFocus import optics.{Getter, Optic} -import optics.Optic.* // get, readOnly, reverseGet, foldMap, andThen +import optics.Optic.* // get, readOnly, reverseGet, foldMap, modify, andThen import schemes.samples.{Bin, BinF, Rose, RoseF} @@ -89,10 +89,10 @@ class SchemesSpec extends Specification: (toStr.get(3) == "4") must beTrue } - // ----- fLayer: the single-layer Forget[F] optic ----- + // ----- fLayer: the single-layer MultiFocus[F] self-traversal ----- - "fLayer is a usable Optic[S,S,S,S,Forget[F]]: to/from round-trip one layer" >> { - val layer: Optic[Bin, Bin, Bin, Bin, Forget[BinF]] = Schemes.fLayer[BinF, Bin] + "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 } @@ -103,6 +103,13 @@ class SchemesSpec extends Specification: .and(layer.foldMap[Int](_ => 1)(Bin.Leaf(9)) == 0) // a leaf has no recursive foci } + "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: 10^6 deep ----- // // The foldLayered machine (the < 512-on-stack / heap-ArrayDeque hybrid) moves the deep recursion From 6fec9a4f9823124ac6f97e51621851da1d66001f Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 17:20:22 +0200 Subject: [PATCH 44/61] =?UTF-8?q?feat(schemes):=20paraLens=20=E2=80=94=20t?= =?UTF-8?q?he=20paramorphism=20as=20a=20lawful,=20composable=20Lens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit para is unconditionally a Getter; this promotes it to a core GetReplaceLens (Optic[S,S,A,A,Tuple2]) so a recursion scheme reads AND writes, and composes with hand-written/derived Lenses on the fused Tuple2 path. The lawfulness boundary, handled honestly: a fold result is not in general a recoverable component of S, so the store-comonad "auto-put" (re-embed the retained subterms, X = F[(S,A)]) makes get-put definitional but leaves put-get conditional on the algebra having a coherent inverse. Rather than ship a Lens that is only conditionally lawful, paraLens takes the coherent put-direction as a parameter — get/enplace then obey the ordinary Lens laws. (The automatic decorated optic stays read-only, a follow-up enrichment of fLayer.) ParaLensSpec pins all three Lens laws on a coherent instance (leftmost-leaf: get = a left-spine paramorphism, enplace = rewrite that leaf) and shows it composing under a core Lens (Box → leftmost leaf). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/constructive/eo/schemes/Schemes.scala | 31 ++++++++- .../eo/schemes/ParaLensSpec.scala | 65 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala 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 a55f56b7..f8061120 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -4,7 +4,8 @@ package schemes import cats.{~>, Monad, Traverse} import data.MultiFocus -import optics.Optic +import optics.{GetReplaceLens, Lens, Optic} +import optics.Optic.get import zoo.* /** Typed recursion schemes as composable optics, over a user-supplied **pattern functor** `F[_]` (+ @@ -388,3 +389,31 @@ object Schemes: (_, layer) => M.map(alg(layer))(b => Attr(b, layer)), ) 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. + * + * '''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.) + * + * @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/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..14197dfb --- /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))) + } From 148e42e6e4a4323281176e04850b0f153df160c5 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 17:50:19 +0200 Subject: [PATCH 45/61] =?UTF-8?q?feat(core):=20BiAffine.assoc=20=E2=80=94?= =?UTF-8?q?=20ship=20the=20deferred=20composition-matrix=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BiAffine (the build-seam carrier — Done = finished/graft, Step = keep-going) shipped its forgetful instances + Graft but, by design, NOT its composition row: its own scaladoc said "AssociativeFunctor[BiAffine] ... deliberately NOT shipped here". That made it the one carrier that couldn't compose — latent, like foldLayeredM was. This adds AssociativeFunctor[BiAffine], a mechanical build-side mirror of Affine.assoc (Done ↔ Miss, Step ↔ Hit, identical Z), so the generic Optic.andThen now resolves for BiAffine-carried optics: biaffine.andThen( biaffine) type-checks and runs, with Done short-circuiting (a finished outer slot ends the composition) and Step threading the focus through inner and recombining the one-layer contexts. This is the keystone the brainstorm's candidate #4 needs — "composing decorated schemes = composing their Xs" — and the prerequisite for re-carriering the build-side schemes (apo/futu) onto BiAffine so they actually consume it. BiAffineSpec gains a composition section reaching all three arms (outer Done, Step∘Step, Step∘Done) with round-trip + short-circuit checks; CompositionMatrixSpec (121) confirms no resolution regression. Cross-carrier Composer bridges into BiAffine remain follow-up, alongside those scheme citizens. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/constructive/eo/data/BiAffine.scala | 56 ++++++++++++++++++- .../eo/schemes/ParaLensSpec.scala | 8 +-- .../dev/constructive/eo/BiAffineSpec.scala | 26 +++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala index 0a65a021..350c6bc1 100644 --- a/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala +++ b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala @@ -4,7 +4,9 @@ package data import cats.{Applicative, Monoid} import accessor.{Graft, PartialAccessor} +import compose.* import forgetful.* +import optics.Optic /** Carrier for the decoration (`Gather`/`Scatter`) family of the recursion-scheme zoo — * [[Affine]]'s data shape worn on the *build* seam. Where `Affine.Miss` means "the read found no @@ -19,9 +21,10 @@ import forgetful.* * concrete `Tuple2` (so `Fst[A]` / `Snd[A]` reduce); carried through an `Optic[…, BiAffine]` * existential, `A` is abstract and the match types stay inert. * - * The composition-matrix row (`AssociativeFunctor[BiAffine]`, `Composer` bridges) is deliberately - * NOT shipped here — it is follow-up work; the laws that need it (`Done`/`Step` coherence across - * `andThen`) live with it. + * Same-carrier composition is shipped as [[BiAffine.assoc]] — the build-side mirror of + * [[Affine.assoc]] (`Done` ↔ `Miss`, `Step` ↔ `Hit`), so `biaffine.andThen(biaffine)` type-checks + * and runs (`Done`/`Step` coherence across `andThen`). The cross-carrier `Composer` bridges into + * `BiAffine` remain follow-up, alongside the recursion-scheme citizens that would consume them. * * @tparam A * existential leftover tuple @@ -131,3 +134,50 @@ object BiAffine: given graft: Graft[BiAffine] with def done[X, B](fst: Fst[X]): BiAffine[X, B] = new Done[X, B](fst) def step[X, B](snd: Snd[X], b: B): BiAffine[X, B] = new Step[X, B](snd, b) + + /** Composition functor for `BiAffine` carriers — the build-side mirror of [[Affine.assoc]] + * (`Done` ↔ `Miss`, `Step` ↔ `Hit`), so the generic `Optic.andThen` resolves for + * `BiAffine`-carried optics. `Z` is identical to Affine's: the outer/inner leftovers nested + * through the `Done`/`Step` arms. `Done` short-circuits (an outer finished slot ends the + * composition); `Step` threads the focus through `inner` and recombines the one-layer contexts. + * + * `Xo` / `Xi` are deliberately unbounded — `BiAffine`'s `Fst` / `Snd` match types stay inert + * when the existential is not a `Tuple`, sound for every concrete optic (each concrete `X` is a + * `Tuple2`), exactly as on [[Affine.assoc]]. + * + * @group Instances + */ + given assoc[Xo, Xi]: AssociativeFunctor[BiAffine, Xo, Xi] with + type Z = (Either[Fst[Xo], (Snd[Xo], Fst[Xi])], (Snd[Xo], Snd[Xi])) + + def composeTo[S, T, A, B, C, D]( + s: S, + outer: Optic[S, T, A, B, BiAffine] { type X = Xo }, + inner: Optic[A, B, C, D, BiAffine] { type X = Xi }, + ): BiAffine[Z, C] = outer.to(s) match + case od: Done[Xo, A] => + new Done[Z, C](Left(od.fst)) + case os: Step[Xo, A] => + inner.to(os.b) match + case id: Done[Xi, C] => + new Done[Z, C](Right((os.snd, id.fst))) + case is: Step[Xi, C] => + new Step[Z, C]((os.snd, is.snd), is.b) + + def composeFrom[S, T, A, B, C, D]( + xd: BiAffine[Z, D], + inner: Optic[A, B, C, D, BiAffine] { type X = Xi }, + outer: Optic[S, T, A, B, BiAffine] { type X = Xo }, + ): T = xd match + case d: Done[Z, D] => + // Fst[Z] = Either[Fst[Xo], (Snd[Xo], Fst[Xi])] — the match-type reduction can't be + // proven at the trait level, so we cast (as on Affine.assoc). + d.fst.asInstanceOf[Either[Fst[Xo], (Snd[Xo], Fst[Xi])]] match + case Left(y) => outer.from(new Done[Xo, B](y)) + case Right((x1, y0)) => + val b: B = inner.from(new Done[Xi, D](y0)) + outer.from(new Step[Xo, B](x1, b)) + case s: Step[Z, D] => + val pair = s.snd.asInstanceOf[(Snd[Xo], Snd[Xi])] + val b: B = inner.from(new Step[Xi, D](pair._2, s.b)) + outer.from(new Step[Xo, B](pair._1, b)) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala index 14197dfb..d0b2f11b 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala @@ -13,8 +13,8 @@ import schemes.samples.{Bin, BinF} 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. + * 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 @@ -60,6 +60,6 @@ class ParaLensSpec extends Specification: "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))) + (composed.get(Box(tree)) === 1) + .and(composed.replace(7)(Box(tree)) === Box(setLeftmost(tree, 7))) } diff --git a/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala index 0260d997..9095b2b7 100644 --- a/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala @@ -49,3 +49,29 @@ class BiAffineSpec extends Specification: List(-1, -100).map(w => toy.from(toy.to(w))) === List(-1, -100) } } + + "BiAffine.assoc — the composition-matrix row" should { + + // A second citizen whose Done fires on an *even* focus, so toy.andThen(innerToy) reaches all + // three composed arms: outer Done (w<0), Step∘Step (w≥0 odd), Step∘Done (w≥0 even). + val innerToy: Optic[Int, Int, Int, Int, BiAffine] { type X = TX } = + new Optic[Int, Int, Int, Int, BiAffine]: + type X = TX + def to(w: Int): BiAffine[X, Int] = + if w % 2 == 0 then new Done[X, Int](w) else new Step[X, Int](List(w), w) + def from(xb: BiAffine[X, Int]): Int = xb match + case d: Done[X, Int] => d.fst + case s: Step[X, Int] => s.b + + val composed = toy.andThen(innerToy) + + "biaffine.andThen(biaffine) type-checks and round-trips across all three arms" in { + // w<0 → outer Done; w≥0 odd → Step∘Step; w≥0 even → Step∘Done. + List(-5, 1, 3, 4, 16, 17).map(w => composed.from(composed.to(w))) === + List(-5, 1, 3, 4, 16, 17) + } + + "outer Done short-circuits the composition (Left arm of Z)" in { + composed.from(composed.to(-9)) === -9 + } + } From c4ecb6aa0c0bbfe26f5bc35215f3d209437e69c0 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 18:05:52 +0200 Subject: [PATCH 46/61] =?UTF-8?q?feat(core):=20BiAffine=20cross-carrier=20?= =?UTF-8?q?bridges=20=E2=80=94=20complete=20the=20matrix=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Composer[Tuple2, BiAffine] (tuple2biaffine) and Composer[Either, BiAffine] (either2biaffine), the build-side mirrors of Affine's tuple2affine/either2affine: a Lens lifts to an always-Step; a Prism maps Right→Step, Left→Done. With BiAffine.assoc (same-carrier) these complete BiAffine's composition-matrix row, so lens/prism compose into BiAffine-carried decorations (e.g. the apo scatter). BiAffineSpec gains round-trip checks for both bridges via .morph[BiAffine]; CompositionMatrixSpec (121) and the full aggregate confirm no resolution regression from the new givens. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/constructive/eo/data/BiAffine.scala | 49 +++++++++++++++++-- .../dev/constructive/eo/BiAffineSpec.scala | 27 ++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala index 350c6bc1..81a4ae49 100644 --- a/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala +++ b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala @@ -21,10 +21,11 @@ import optics.Optic * concrete `Tuple2` (so `Fst[A]` / `Snd[A]` reduce); carried through an `Optic[…, BiAffine]` * existential, `A` is abstract and the match types stay inert. * - * Same-carrier composition is shipped as [[BiAffine.assoc]] — the build-side mirror of - * [[Affine.assoc]] (`Done` ↔ `Miss`, `Step` ↔ `Hit`), so `biaffine.andThen(biaffine)` type-checks - * and runs (`Done`/`Step` coherence across `andThen`). The cross-carrier `Composer` bridges into - * `BiAffine` remain follow-up, alongside the recursion-scheme citizens that would consume them. + * The composition-matrix row is shipped: [[BiAffine.assoc]] (same-carrier `andThen`, the + * build-side mirror of [[Affine.assoc]] — `Done` ↔ `Miss`, `Step` ↔ `Hit`) plus the cross-carrier + * bridges [[BiAffine.tuple2biaffine]] (Lens → BiAffine) and [[BiAffine.either2biaffine]] (Prism → + * BiAffine), mirroring `Affine`'s. So `biaffine.andThen(biaffine)` and + * `lens`/`prism`-into-`BiAffine` compositions all resolve. * * @tparam A * existential leftover tuple @@ -181,3 +182,43 @@ object BiAffine: val pair = s.snd.asInstanceOf[(Snd[Xo], Snd[Xi])] val b: B = inner.from(new Step[Xi, D](pair._2, s.b)) outer.from(new Step[Xo, B](pair._1, b)) + + /** Lens → BiAffine — the build-side mirror of [[Affine.tuple2affine]]. A `Tuple2` optic has no + * finished arm, so it lifts to an always-`Step` (the `Done` arm is reached only when a + * downstream composition finishes, carrying the rebuilt `T`). Lets a Lens compose with a + * `BiAffine`-carried decoration (e.g. `lens.andThen(apoScatter)`). + * + * @group Instances + */ + given tuple2biaffine: Composer[Tuple2, BiAffine] with + + def to[S, T, A, B](o: Optic[S, T, A, B, Tuple2]): Optic[S, T, A, B, BiAffine] = + new Optic[S, T, A, B, BiAffine]: + type X = (T, o.X) + def to(s: S): BiAffine[X, A] = + val (xo, a) = o.to(s) + new Step[X, A](xo, a) + def from(b: BiAffine[X, B]): T = + b match + case d: Done[X, B] => d.fst + case s: Step[X, B] => o.from((s.snd, s.b)) + + /** Prism → BiAffine — the build-side mirror of [[Affine.either2affine]]. The `Either` + * decomposition maps straight onto `Done` (the `Left` / no-build arm) and `Step` (the `Right` / + * keep-going arm). Lets a Prism compose with a `BiAffine`-carried decoration. + * + * @group Instances + */ + given either2biaffine: Composer[Either, BiAffine] with + + def to[S, T, A, B](o: Optic[S, T, A, B, Either]): Optic[S, T, A, B, BiAffine] = + new Optic[S, T, A, B, BiAffine]: + type X = (o.X, S) + def to(s: S): BiAffine[X, A] = + o.to(s) match + case Right(a) => new Step[X, A](s, a) + case Left(x) => new Done[X, A](x) + def from(xb: BiAffine[X, B]): T = + xb match + case d: Done[X, B] => o.from(Left(d.fst)) + case s: Step[X, B] => o.from(Right(s.b)) diff --git a/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala index 9095b2b7..0cfbba00 100644 --- a/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala @@ -75,3 +75,30 @@ class BiAffineSpec extends Specification: composed.from(composed.to(-9)) === -9 } } + + "BiAffine cross-carrier bridges" should { + + "Composer[Tuple2, BiAffine] lifts a Lens-shaped optic to always-Step, 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 bi = tupleOptic.morph[BiAffine] + bi.from(bi.to((7, "x"))) === ((7, "x")) + } + + "Composer[Either, BiAffine] maps Right→Step and Left→Done, 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 bi = eitherOptic.morph[BiAffine] + (bi.from(bi.to(Some(5))) === Some(5)).and(bi.from(bi.to(None)) === None) + } + } From e87e5f335c2d74fed51de26705fb52381a6e75ed Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 18:11:19 +0200 Subject: [PATCH 47/61] feat(schemes): re-carrier apo onto BiAffine via apoScatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apo's per-slot residual is now a genuine BiAffine-carried optic. Schemes.apoScatter (Apo.scatter) = Optic[Either[S,A], Unit, A, Unit, BiAffine]{ X = (S, Unit) }: Left(s) → Done(s) (the O(1) graft, Fst[X] = S), Right(a) → Step((), a) (keep unfolding). The X is refined/exposed so Fst[X] reduces at use sites. Apo's engine now constructs AND consumes its decision through this optic — every slot goes residual → scatter.to → Done/Step → engine — so apo speaks the carrier BiAffine was written for. The shared pure foldLayeredOr engine still recurses over an Either at its boundary (it's shared with elgot/cozygo); the Done/Step decision collapses onto that boundary at the last step. As a real Optic[…, BiAffine] value, apoScatter composes via BiAffine.assoc (proven) and the either2biaffine bridge. ApoScatterSpec pins Done/Step semantics, three composed arms (Step∘Step, outer-Done short-circuit, inner-Done), and that the re-carriered scheme still builds with grafts intact; full apo behaviour suite (ZooExtendedSpec) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dev/constructive/eo/schemes/Schemes.scala | 10 +++- .../dev/constructive/eo/schemes/zoo/Apo.scala | 46 ++++++++++++--- .../eo/schemes/ApoScatterSpec.scala | 58 +++++++++++++++++++ 3 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala 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 f8061120..92594e43 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -3,7 +3,7 @@ package schemes import cats.{~>, Monad, Traverse} -import data.MultiFocus +import data.{BiAffine, MultiFocus} import optics.{GetReplaceLens, Lens, Optic} import optics.Optic.get import zoo.* @@ -145,6 +145,14 @@ object Schemes: 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.BiAffine]] 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.BiAffine.assoc]] and the [[data.BiAffine.either2biaffine]] 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, BiAffine] { 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]]. */ 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 index 998c86dd..58236c53 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala @@ -4,6 +4,9 @@ package zoo import cats.Traverse +import data.BiAffine +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 @@ -13,9 +16,17 @@ import cats.Traverse * 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]]. * - * '''O(1) graft.''' A `Left(s)` subtree is placed into its result slot **by reference** — - * [[Machines.foldLayeredOr]]'s `Left` arm returns it without recursing or re-`project`ing. - * Stack-safe. + * '''The residual is a [[data.BiAffine]] optic.''' apo's per-slot decision is exactly `BiAffine`'s + * build seam — `Left(s)` is `Done(s)` (a finished slot, the O(1) graft), `Right(a)` is `Step((), + * a)` (keep unfolding). [[Apo.scatter]] exposes that decision as a composable `BiAffine`-carried + * optic (a *scatter*), and this engine constructs and consumes it through that optic: every slot + * goes `residual → scatter.to → Done/Step → 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 `Done`/`Step` decision is + * collapsed onto that boundary at the last step. + * + * '''O(1) graft.''' A `Done(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], @@ -24,13 +35,34 @@ final class Apo[F[_], A, S](private[zoo] val coalg: A => F[Either[S, A]])(using 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]( - { - case Left(s) => Left(s) // finished subtree — grafted by reference, O(1) - case Right(a) => Right(coalg(a)) // seed — keep unfolding - }, + residual => + sc.to(residual) + .fold[Either[S, F[Either[S, A]]]]( + s => Left(s), // Done — finished subtree, grafted by reference (O(1)) + (_, a) => Right(coalg(a)), // Step — 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.BiAffine]] build seam — a *scatter* decoration. + * `Left(s) → Done(s)` (the O(1) graft); `Right(a) → Step((), 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[…, BiAffine]` + * value it composes via [[data.BiAffine.assoc]] and the [[data.BiAffine.either2biaffine]] 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, BiAffine] { type X = (S, Unit) } = + new Optic[Either[S, A], Unit, A, Unit, BiAffine]: + type X = (S, Unit) + def to(e: Either[S, A]): BiAffine[X, A] = e match + case Left(s) => new BiAffine.Done[X, A](s) + case Right(a) => new BiAffine.Step[X, A]((), a) + def from(b: BiAffine[X, Unit]): Unit = () 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..ec73ab22 --- /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.BiAffine +import optics.Optic +import optics.Optic.* // reverseGet + +import schemes.samples.{Bin, BinF} + +/** apo re-carriered onto [[data.BiAffine]]: its per-slot residual is now [[Schemes.apoScatter]], a + * composable `BiAffine`-carried scatter optic (`Left → Done`, the O(1) graft; `Right → Step`, keep + * unfolding), and apo's engine constructs + consumes that decision through it. Pins the scatter's + * `Done`/`Step` semantics, that it composes via `BiAffine.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 → Done (carrying the grafted subtree)" >> { + sc.to(Left(Bin.Leaf(7))).fold(s => s, (_, _) => Bin.Leaf(-1)) === Bin.Leaf(7) + } + + "apoScatter maps Right → Step (carrying the keep-going focus)" >> { + sc.to(Right(9)).fold(_ => -1, (_, b) => b) === 9 + } + + // A second BiAffine optic on the focus Int — Done on negatives — to compose under apoScatter. + private val innerToy: Optic[Int, Unit, Int, Unit, BiAffine] { type X = (Int, Unit) } = + new Optic[Int, Unit, Int, Unit, BiAffine]: + type X = (Int, Unit) + def to(n: Int): BiAffine[X, Int] = + if n < 0 then new BiAffine.Done[X, Int](n) else new BiAffine.Step[X, Int]((), n) + def from(b: BiAffine[X, Unit]): Unit = () + + private val composed = sc.andThen(innerToy) + + "apoScatter composes via BiAffine.assoc — Step∘Step threads the focus" >> { + composed.to(Right(5)).fold(_ => -1, (_, b) => b) === 5 + } + + "apoScatter composes — outer Done (graft) short-circuits the composition" >> { + composed.to(Left(Bin.Leaf(0))).fold(_ => -1, (_, b) => b) === -1 + } + + "apoScatter composes — inner Done short-circuits the keep-going arm" >> { + composed.to(Right(-3)).fold(_ => -1, (_, b) => b) === -1 + } + + "the re-carriered apo still builds: Done grafts, Step 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))) + } From e98731c76d069cda463eab57ff5a524ee111a213 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Sun, 14 Jun 2026 20:26:49 +0200 Subject: [PATCH 48/61] refactor(schemes): bring the M-family + unfold drive to the pure side's factoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two surface-preserving dedups the f1a3266 audit didn't reach (it predates the M-family and BiAffine work): 1. M-lifting adapters. The 8 *M factories re-inlined the pure side's layer wiring lifted into M. Mirror it: Coattr.expandM / Attr.decorateM (M-twins of expand/decorate) + three private lifters in Schemes (liftProject / liftCoalg / embedM) for the project/coalg/embed wiring. Each *M factory now reads as terse as its pure twin, and the Right/pure/Either wrapping lives in one place. 2. Machines.buildLayered — foldLayered with the combine fixed to embed, the shape every non-grafting unfold shares. ana/futu/cozygo/comutu and the unfold halves of meta/metaChrono drop their repeated `(_, fr) => E.embed(fr)` (6 sites). No public surface change; SchemesMSpec / ZooSpec / ZooExtendedSpec (96) and the full aggregate (325) pass unchanged. Left the 10 function-wrapping citizens and apoScatter's engine round-trip alone (merging would cost named-type surface / needs the shared-engine re-carrier respectively). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../constructive/eo/schemes/Machines.scala | 10 +++ .../dev/constructive/eo/schemes/Schemes.scala | 66 +++++++++---------- .../dev/constructive/eo/schemes/zoo/Ana.scala | 2 +- .../constructive/eo/schemes/zoo/Attr.scala | 10 +++ .../constructive/eo/schemes/zoo/Coattr.scala | 12 ++++ .../constructive/eo/schemes/zoo/Comutu.scala | 2 +- .../constructive/eo/schemes/zoo/Cozygo.scala | 2 +- .../constructive/eo/schemes/zoo/Futu.scala | 2 +- .../constructive/eo/schemes/zoo/Meta.scala | 2 +- .../eo/schemes/zoo/MetaChrono.scala | 3 +- .../eo/schemes/ApoScatterSpec.scala | 3 +- 11 files changed, 73 insertions(+), 41 deletions(-) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala index 7bc9f3a6..ca66cec7 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -232,6 +232,16 @@ private[schemes] object Machines: 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 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 92594e43..ed3d8698 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -274,11 +274,28 @@ object Schemes: // ===== 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`), and the pure - // layer decorations ([[zoo.Attr.decorate]] / [[zoo.Coattr.expand]] / the para zip / the apo - // residual) compose with `M` for free. `M` must be single-pass / linear / sequential (`Id`, + // 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]]. */ @@ -287,7 +304,7 @@ object Schemes: F: Traverse[F], P: Project[F, S], ): FoldM[S, A, M, Nothing] = - FoldM(Machines.foldLayeredM[M, F, S, A](s => M.pure(Right(P.project(s))), (_, fr) => alg(fr))) + 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]`. @@ -299,7 +316,7 @@ object Schemes: ): FoldM[S, A, M, F[(S, A)]] = FoldM( Machines.foldLayeredM[M, F, S, A]( - s => M.pure(Right(P.project(s))), + liftProject(P.project), (s, fa) => val it = F.toList(fa).iterator alg(F.map(P.project(s))(sub => (sub, it.next()))), @@ -314,10 +331,8 @@ object Schemes: F: Traverse[F], P: Project[F, S], ): FoldM[S, A, M, Attr[F, A]] = - val toAttr = Machines.foldLayeredM[M, F, S, Attr[F, A]]( - s => M.pure(Right(P.project(s))), - (_, layer) => M.map(alg(layer))(a => Attr(a, layer)), - ) + 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 = @@ -328,12 +343,7 @@ object Schemes: F: Traverse[F], E: Embed[F, S], ): BuildM[S, Seed, M, S] = - BuildM( - Machines.foldLayeredM[M, F, Seed, S]( - seed => M.map(coalg(seed))(Right(_)), - (_, fr) => M.pure(E.embed(fr)), - ) - ) + 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), @@ -349,7 +359,7 @@ object Schemes: case Left(s) => M.pure(Left(s)) case Right(a) => M.map(coalg(a))(Right(_)) }, - (_, fr) => M.pure(E.embed(fr)), + embedM, ) BuildM(a => run(Right(a))) @@ -362,13 +372,7 @@ object Schemes: F: Traverse[F], E: Embed[F, S], ): BuildM[S, A, M, Coattr[F, A]] = - val run = Machines.foldLayeredM[M, F, Coattr[F, A], S]( - { - case Coattr.Pure(a) => M.map(coalg(a))(Right(_)) - case Coattr.Roll(layer) => M.pure(Right(layer)) - }, - (_, fr) => M.pure(E.embed(fr)), - ) + 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 = @@ -378,9 +382,7 @@ object Schemes: M: Monad[M], F: Traverse[F], ): FoldM[Seed, A, M, Nothing] = - FoldM( - Machines.foldLayeredM[M, F, Seed, A](seed => M.map(coalg(seed))(Right(_)), (_, fr) => alg(fr)) - ) + 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. @@ -389,13 +391,11 @@ object Schemes: 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]]( - { - case Coattr.Pure(a) => M.map(coalg(a))(Right(_)) - case Coattr.Roll(layer) => M.pure(Right(layer)) - }, - (_, layer) => M.map(alg(layer))(b => Attr(b, layer)), - ) + 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 ======================================== 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 index fb281377..15816233 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Ana.scala @@ -16,7 +16,7 @@ final class Ana[F[_], Seed, S](private[zoo] val coalg: Seed => F[Seed])(using type X = S private[zoo] val build: Seed => S = - Machines.foldLayered[F, Seed, S](coalg, (_, fr) => E.embed(fr)) + Machines.buildLayered[F, Seed, S](coalg) protected def write(seed: Seed): S = build(seed) 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 index 3792b556..ec0821d7 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Attr.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Attr.scala @@ -2,6 +2,8 @@ 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. @@ -24,3 +26,11 @@ object Attr: */ 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/Coattr.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coattr.scala index a87565cf..51555f3c 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coattr.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Coattr.scala @@ -2,6 +2,8 @@ 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. @@ -28,3 +30,13 @@ object Coattr: 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/Comutu.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Comutu.scala index 57e67eef..23af2ca8 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Comutu.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Comutu.scala @@ -25,7 +25,7 @@ final class Comutu[F[_], A, B, S]( val expand: Either[A, B] => F[Either[A, B]] = case Left(a) => coalgA(a) case Right(b) => coalgB(b) - val run = Machines.foldLayered[F, Either[A, B], S](expand, (_, fr) => E.embed(fr)) + 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 index e3200a2c..7c6a6f5b 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cozygo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Cozygo.scala @@ -29,7 +29,7 @@ final class Cozygo[F[_], A, B, 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.foldLayered[F, Either[B, A], S](expand, (_, fr) => E.embed(fr)) + 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/Futu.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala index 6c24f3e3..b74b55c3 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Futu.scala @@ -18,7 +18,7 @@ final class Futu[F[_], A, S](private[zoo] val coalg: A => F[Coattr[F, A]])(using type X = Coattr[F, A] private[zoo] val build: A => S = - val run = Machines.foldLayered[F, Coattr[F, A], S](Coattr.expand(coalg), (_, fr) => E.embed(fr)) + 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) 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 index 16b3e671..94c98841 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Meta.scala @@ -35,5 +35,5 @@ object Meta: 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.foldLayered[G, A, T](coalg, (_, gr) => E.embed(gr)) + 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 index f77f6d9a..c933bdc2 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/MetaChrono.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/MetaChrono.scala @@ -33,7 +33,6 @@ object MetaChrono: 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.foldLayered[G, Coattr[G, A], T](Coattr.expand(coalg), (_, gr) => E.embed(gr)) + 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/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala index ec73ab22..afb936e7 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala @@ -15,7 +15,8 @@ import schemes.samples.{Bin, BinF} * composable `BiAffine`-carried scatter optic (`Left → Done`, the O(1) graft; `Right → Step`, keep * unfolding), and apo's engine constructs + consumes that decision through it. Pins the scatter's * `Done`/`Step` semantics, that it composes via `BiAffine.assoc`, and that the scheme still builds - * (graft intact) after the re-carriering. */ + * (graft intact) after the re-carriering. + */ class ApoScatterSpec extends Specification: private val sc = Schemes.apoScatter[Bin, Int] From d2510e03a10f4543907adc59d231ce5080d546ed Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 4 Sep 2026 15:10:49 +0200 Subject: [PATCH 49/61] =?UTF-8?q?build:=20drop=20the=20schemes-laws=20modu?= =?UTF-8?q?le=20=E2=80=94=20its=20subject=20(the=20untyped=20PSVec=20hylo)?= =?UTF-8?q?=20is=20gone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schemes-laws existed solely to law-check the untyped PSVec hylo fusion (Schemes.hylo/ana/cata), which the typed-recursion-schemes work deletes. The branch predates the module, so the rebase could not remove it; this reconciliation commit does: module directory, build.sbt definition + aggregate + mutationAll entry, CLAUDE.md rows, and the regenerated ci.yml cache paths. Typed-scheme laws live in schemes' own test suite (SchemesFLawsSpec / SchemesFMSpec / zoo laws). A typed-API HyloLaws rewrite is the natural follow-up if the law artifact should return. --- .github/workflows/ci.yml | 4 +- CLAUDE.md | 5 +- build.sbt | 20 ------- .../eo/schemes/laws/HyloLaws.scala | 44 --------------- .../schemes/laws/discipline/HyloTests.scala | 23 -------- .../eo/schemes/laws/package.scala | 17 ------ .../eo/schemes/laws/HyloLawsSpec.scala | 56 ------------------- 7 files changed, 4 insertions(+), 165 deletions(-) delete mode 100644 schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/HyloLaws.scala delete mode 100644 schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/discipline/HyloTests.scala delete mode 100644 schemes-laws/src/main/scala/dev/constructive/eo/schemes/laws/package.scala delete mode 100644 schemes-laws/src/test/scala/dev/constructive/eo/schemes/laws/HyloLawsSpec.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index affd8b5c..513c2cd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,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/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/build.sbt b/build.sbt index 7897c74f..00a4b31d 100644 --- a/build.sbt +++ b/build.sbt @@ -597,7 +597,6 @@ lazy val root: Project = project jsoniterIntegration, zioIntegration, schemes, - schemesLaws, ) ++ (if (kyoBuildActive) Seq[ProjectReference](kyoIntegration) else Seq.empty)) * ) .settings(commonSettings *) @@ -688,24 +687,6 @@ lazy val schemes: Project = project Test / javaOptions += "-Xss8m", ) -// 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, - ) - // Auto-derivation of optics for product / sum types via quoted macros, // built on Mateusz Kubuszok's `hearth` macro-commons library. Kept out // of `core` so agents that only want the hand-written optics don't @@ -1234,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/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, - ) From 016b1378b3899805291e0f6e90b62d8cccecba25 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 4 Sep 2026 15:14:38 +0200 Subject: [PATCH 50/61] =?UTF-8?q?fix(core):=20drop=20the=20unused=20Eval?= =?UTF-8?q?=20import=20from=20Plated=20=E2=80=94=20rebase-merge=20artifact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/src/main/scala/dev/constructive/eo/optics/Plated.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e9d59ce4..46660794 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, Traverse} +import cats.Traverse import cats.Eval import java.util.ArrayDeque From 2d51b04c6ed1bee43e85d538920fef4fdb646ccd Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 4 Sep 2026 14:35:24 +0200 Subject: [PATCH 51/61] docs: typed-schemes bibliography + merge-readiness cleanup plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/research/2026-06-15-typed-schemes-bibliography.md: anchor paper (O'Connor's Multiplate, arXiv:1103.2841) + its relevant references and citations, the recursion-schemes canon, and the Scala-ecosystem implementations, each mapped to what PR #24's surface claims. - docs/plans/2026-06-15-001-cleanup-typed-schemes-merge-plan.md: the merge-blocking cleanup (schemes.md teaches the retired 2-arg algebra API and a removed zoo.Gather type — the docs build is the red CI gate) and the non-blocking polish items, sequenced. --- ...15-001-cleanup-typed-schemes-merge-plan.md | 134 ++++++++++++++ .../2026-06-15-typed-schemes-bibliography.md | 163 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 docs/plans/2026-06-15-001-cleanup-typed-schemes-merge-plan.md create mode 100644 docs/research/2026-06-15-typed-schemes-bibliography.md 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..f2bf8aaa --- /dev/null +++ b/docs/plans/2026-06-15-001-cleanup-typed-schemes-merge-plan.md @@ -0,0 +1,134 @@ +--- +title: "cleanup: typed recursion schemes merge-readiness (bibliography + rough edges)" +type: cleanup +status: open +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: the `BiAffine` 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 the `BiAffine.assoc` matrix row. 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. Fix `site/docs/schemes.md` — it teaches a retired API (blocks `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. `schemes-laws/` is an empty directory tracked in the 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 `BiAffine`/`Graft`/`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. 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 BiAffine 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; +BiAffine 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. + +## 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, BiAffine 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..52c0d300 --- /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 BiAffine 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 BiAffine's + composition-matrix row 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, BiAffine matrix row, higher-order decoration) land so the citations + grow with the surface. From cdd51755b71c0beb381b0635fbc9a2d1fd9f29f4 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 4 Sep 2026 15:10:01 +0200 Subject: [PATCH 52/61] chore(schemes): resolve Scaladoc link warnings + drop empty schemes-laws tree - Schemes.scala / ChronoSpec / ParaLensSpec / SchemesMSpec: fix or inline the Scaladoc links that could not resolve ([[Optic]], [[FusionSpec]], [[Traversal.each]], [[Coattr]]/[[Attr]], [[Lens]], [[Machines.foldLayeredM]]). - schemes-laws/: remove the empty untracked directory tree (can be recreated when the D5 discipline law specs get their own module). --- .../scala/dev/constructive/eo/schemes/Schemes.scala | 10 +++++----- .../scala/dev/constructive/eo/schemes/ChronoSpec.scala | 4 ++-- .../dev/constructive/eo/schemes/ParaLensSpec.scala | 2 +- .../dev/constructive/eo/schemes/SchemesMSpec.scala | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) 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 ed3d8698..78985c4c 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -13,9 +13,9 @@ import zoo.* * * ==The thesis== * - * A recursion scheme is an [[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**: + * 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 | * |:-----------|:--------------------------------|:----------------------------------------------| @@ -48,7 +48,7 @@ import zoo.* * [[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 + * `FusionSpec` pins the hylo law and witnesses the deforestation (the fused refold never calls * `project`/`embed`). * * The **fold→unfold** seam `cata.meta(ana)` is the direction-dual ([[meta]], the metamorphism), @@ -76,7 +76,7 @@ object Schemes: * whose foci are the node's immediate children `F[S]`. * * 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 + * `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; diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala index 88d2b43d..46303073 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala @@ -11,8 +11,8 @@ 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`**. + * `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]]. diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala index d0b2f11b..5eb41073 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ParaLensSpec.scala @@ -12,7 +12,7 @@ import schemes.samples.{Bin, BinF} // scheme Lens. final case class Box(t: Bin) -/** Step 3: [[Schemes.paraLens]] — the paramorphism promoted to a writable [[Lens]]. `get` is a +/** 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. * diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala index 6cbea840..9739bf60 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala @@ -13,7 +13,7 @@ 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]]. + * `anaM` / `apoM` / `futuM` / `hyloM` / `chronoM`, all riding `Machines.foldLayeredM`. * * Two anchors per scheme: * From 190804e6a0b73f66bfaf85b3663da7a218f4223f Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 4 Sep 2026 15:37:27 +0200 Subject: [PATCH 53/61] fix(docs): rewrite site/docs/schemes.md against the shipped schemes API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page taught the retired surface — node-supplied 2-arg algebras ((node, folded) => ...), a removed zoo.Gather type, 4-type-arg cata, Forget[M]-carried FoldM consumed via .run — which failed the docs build (10 mdoc errors, the red CI gate). Rewritten against the shipped API: - algebras are node-blind 1-arg (F[A] => A / F[(S, A)] => A); - zygo shown as the named citizen constructor Schemes.zygo[...](aux)(alg); - the M family reads via .get / .reverseGet yielding M[...], hyloM the fused effectful spelling (no Kleisli andThen exists on the citizens); - fLayer rides MultiFocus[F] (read+write), not Forget[F]; - the zoo table drops the Gather/Scatter vocabulary (named citizens on the BiAffine carrier; assoc + cross-carrier bridges shipped); - the BiAffine section no longer claims the matrix row and elgot/coelgot are follow-ups — both shipped; - lens-composition example uses the unambiguous spelling: Getter.andThen is 3-way ambiguous for Direct-carried citizens (recorded as cleanup C8). sbt scalafmtCheckAll scalafmtSbtCheck docs/mdoc docs/laikaSite all green. --- ...15-001-cleanup-typed-schemes-merge-plan.md | 19 +- site/docs/schemes.md | 180 +++++++++--------- 2 files changed, 103 insertions(+), 96 deletions(-) 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 index f2bf8aaa..caecf2b0 100644 --- 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 @@ -1,7 +1,7 @@ --- title: "cleanup: typed recursion schemes merge-readiness (bibliography + rough edges)" type: cleanup -status: open +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 @@ -50,7 +50,7 @@ bibliography consolidates them. ## Cleanup items (merge-blocking first) -### C1. Fix `site/docs/schemes.md` — it teaches a retired API (blocks `docs/mdoc`, CI red) +### 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: @@ -72,7 +72,7 @@ link target exists; the warnings pre-date and are informational). touched) gets one line pointing at the new bibliography file, so the branch's citations live in exactly one place. No claims change. -### C3. `schemes-laws/` is an empty directory tracked in the tree +### 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 @@ -90,7 +90,7 @@ public surface: new `schemes` artifact, new core `BiAffine`/`Graft`/`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. Doc/code contradiction: "referenced nowhere" claims in `schemes.md` +### 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 @@ -118,6 +118,17 @@ once after C1 so the "before/after pin" rows reflect the final merged state 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) → diff --git a/site/docs/schemes.md b/site/docs/schemes.md index 3abbbdcf..228d0e1d 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -11,7 +11,7 @@ constructors** (compile-time arity safety, no positional indexing): | `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` | `Forget[M]`-carried | effectful steps in a `Monad[M]` | +| `cataM` / `anaM` / `hyloM` | `.get`/`.reverseGet` yield `M[…]` | effectful steps in a `Monad[M]` | 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, @@ -69,11 +69,9 @@ val binTree: Bin = Bin.Branch(Bin.Leaf(1), Bin.Branch(Bin.Leaf(2), Bin.Leaf(3))) typed `BinF[A]`** — `l` and `r` are `A`, by name, no positional indexing: ```scala mdoc:silent -val sumLeavesF: Getter[Bin, Int] = - Schemes.cata[BinF, Bin, Int] { (_, folded) => - folded match - case BinF.LeafF(n) => n - case BinF.BranchF(l, r) => l + r +val sumLeavesF = Schemes.cata[BinF, Bin, Int] { + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r } ``` @@ -92,13 +90,12 @@ val buildBin = Schemes.ana[BinF, Int, Bin] { n => } // fused: count the leaves directly, building no Bin -val countLeavesF: Getter[Int, Int] = - Schemes.hylo[BinF, Int, Int]( +val countLeavesF = Schemes.hylo[BinF, Int, Int]( coalg = n => if n <= 0 then BinF.LeafF(1) else BinF.BranchF(0, n - 1), - alg = (_, folded) => - folded match - case BinF.LeafF(_) => 1 - case BinF.BranchF(l, r) => l + r, + alg = { + case BinF.LeafF(_) => 1 + case BinF.BranchF(l, r) => l + r + }, ) ``` @@ -108,25 +105,24 @@ countLeavesF.get(3) // same count, fused — no Bin materiali countLeavesF.get(1000000) // stack-safe: the heap machine, O(depth) heap ``` -`cata` and `hylo` are **`Getter`s** (forward reads) and `ana` is a **`Review`** (its build-only -dual), so they compose with the rest of the optic algebra: `cata`/`hylo` via `andThen`, and the +`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)). **Choosing a path:** reach for `cata`/`ana`/`hylo` (default) when you -want zero -boilerplate; reach for `cata`/`ana`/`hylo` when you want the algebra to be type-checked against -named constructors. Deriving `Project`/`Embed` from the `S`↔`F` correspondence is future work; today -they are hand-written (as above). +[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 are `Getter`s, 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. Read-only optics compose -`Getter`-to-`Getter`, so wrap the lens's read in a `Getter` (or just read at the leaf, -`cata(alg).get(lens.get(record))`) — the same composed lens still *writes* the field back: +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.Lens @@ -138,8 +134,8 @@ 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 -// wrap the composed lens's read in a Getter, then andThen the scheme → reusable Getter[Doc, Int] -val docLeafSum = Getter[Doc, Bin](deepTree.get).andThen(sumLeavesF) +// 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)) ``` @@ -150,35 +146,38 @@ deepTree.replace(Bin.Leaf(0))(record) // the SAME composed lens writes the field ``` The single peel/glue layer is also available on its own as `Schemes.fLayer[F, S]`, an -`Optic[S, S, S, S, Forget[F]]` (`to = project`, `from = embed`) — the typed analogue of `Plated`'s -`plate` for one layer. Given a `Foldable[F]` it reads a node's immediate foci via `.foldMap`. It is -primarily the proof that a typed `F` is an optic carrier; the recursive schemes drive `project`/ -`embed` themselves rather than composing `fLayer`. +`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**, and eo ships it as a vocabulary of -*decoration optics* (the `Gather`/`Scatter` family, worn on the new `BiAffine` carrier — see below): +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 the +`BiAffine` carrier (see below): -| scheme | decoration | shape | `Gather`/`Scatter` value | -|---|---|---|---| -| cata / ana | none | — | `Gather.cata` / `Scatter.ana` | -| **para** | child slots carry the original subterms | product | (native only) | -| **apo** | child slots may graft a finished subtree | sum | (native only) | -| **histo** | full decorated history per child (`Attr`) | iterated product | `Gather.histo` | -| **futu** | multiple layers per step (`Coattr`) | iterated sum | `Scatter.futu` | -| zygo / dyna / … | user-written `Gather`/`Scatter` values | — | (yours — example 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] { (_, layer) => - layer match - case BinF.LeafF(_) => 0 - case BinF.BranchF((ls, l), (_, r)) => - l + r + (ls match { case Bin.Leaf(_) => 1; case _ => 0 }) +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 }) } ``` @@ -211,14 +210,13 @@ O(n) `Attr` cells): 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] { (_, layer) => - layer match - 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) +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) } ``` @@ -245,8 +243,8 @@ hylo law). ```scala mdoc:silent val zooExpand: Int => BinF[Int] = n => if n <= 1 then BinF.LeafF(1) else BinF.BranchF(n / 2, n - n / 2) -val zooSum: (Bin, BinF[Int]) => Int = (_, fa) => - fa match { case BinF.LeafF(n) => n; case BinF.BranchF(l, r) => l + r } +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)) ``` @@ -255,35 +253,31 @@ val fusedLeafSum = Schemes.ana[BinF, Int, Bin](zooExpand).cross(Schemes.cata(zoo fusedLeafSum.get(6) ``` -### Write your own decoration: zygo as a `Gather` value +### A decorated fold with a helper: `zygo` -The generality that droste exposes as `gcata`/`gana` lives here as the **public `Gather`/`Scatter` decoration optics**: -a decoration is an optic over the `BiAffine` carrier (fold side: `from` = *gather*; unfold side: -`to` = *scatter*, `from` on `Step` = the seed-injecting unit). A zygomorphism — the algebra -consults a helper fold alongside each child's result — is a user-written gather value, consumed -by the same `cata(decor)(galg)` driver as the named members: +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.schemes.zoo.Gather - -def zygo[B](helper: BinF[B] => B): Gather[BinF, (B, Int), Int] = - new Gather[BinF, (B, Int), Int]: - def gather(layer: BinF[(B, Int)], a: Int): (B, Int) = - (helper(summon[Traverse[BinF]].map(layer)(_._1)), a) - val leafCount: BinF[Int] => Int = { case BinF.LeafF(_) => 1; case BinF.BranchF(l, r) => l + r } -// sum, with the helper count available at every branch -val sumWithCount = Schemes.cata[BinF, Bin, (Int, Int), Int](zygo(leafCount)) { (_, layer) => - layer match - case BinF.LeafF(n) => n - case BinF.BranchF((_, l), (cr, r)) => l + r + 0 * cr // helper in scope per child +// 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 } ``` -User-written values run the generic decoration route (one decoration dispatch + `Step` per -node); the named values dispatch to native engine routes. +```scala mdoc +sumWithCount.get(binTree) +``` + +The named citizens dispatch to native engine routes — the decoration is consumed inside the +machine, not as a per-node optic dispatch. ### Effectful steps: `cataM` / `anaM` / `hyloM` @@ -291,11 +285,10 @@ When producing a layer is itself effectful — fetching a node's children from a `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). Results are `Forget[M]`-carried `FoldM` citizens consumed via `.run`; -`anaM.andThen(cataM)` is the materialising effectful hylo (Kleisli `flatMap` — `M[S]` built, then -folded), with `Schemes.hyloM` the fused one-pass spelling. (On the M rung the effect only fits the -Kleisli read slot, so both `cataM` and `anaM` are `FoldM`s composed by `andThen` — the pure rung's -`Review`/`Getter` `cross` duality collapses into Kleisli arrows here.) +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 @@ -305,26 +298,29 @@ type Counted[T] = State[Int, T] // counts service calls, arbo's GetSellOptions s def fetchLayer(n: Int): Counted[BinF[Int]] = State(calls => (calls + 1, zooExpand(n))) -val countedLeafSum = - Schemes - .anaM[Counted, BinF, Int, Bin](fetchLayer) - .andThen(Schemes.cataM[Counted, BinF, Bin, Int]((s, fa) => State.pure(zooSum(s, fa)))) +// 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)), +) ``` ```scala mdoc -countedLeafSum.run(6).run(0).value // (service calls, leaf sum) — one fused pass +countedLeafSum.get(6).run(0) // (service calls, leaf sum) — one fused pass ``` ### The BiAffine carrier -The decoration optics' carrier is new in core: **`BiAffine`** — `Affine`'s data shape worn on the *build* -seam. `Step(context, focus)` keeps going; `Done(payload)` means "this slot is already finished — -do not call the coalgebra" (apo grafts a finished subtree, futu unrolls a prebuilt layer). Its -laws are the graft-finality and round-trip equations in `cats-eo-laws`. Composition here is -scoped to the shipped seams — the `ana.cross(cata)` refold (pure) and `FoldM.andThen` (M-path), -plus the fused `hylo`/`hyloM` drivers; -BiAffine's full composition-matrix row is follow-up work, as are the elgot/coelgot decorations -(the answer-level short-circuit, which the M machine's internals are already shaped for). +The decoration machinery's carrier is new in core: **`BiAffine`** — `Affine`'s data shape worn on +the *build* seam. `Step(context, focus)` keeps going; `Done(payload)` means "this slot is already +finished — do not call the coalgebra" (apo grafts a finished subtree, futu unrolls a prebuilt +layer). Its laws are the graft-finality and round-trip equations in `cats-eo-laws`. The +composition-matrix row is shipped: `BiAffine.assoc` (same-carrier `andThen` — `Done` ↔ `Miss`, +`Step` ↔ `Hit`) plus the cross-carrier bridges from `Tuple2` (Lens) and `Either` (Prism), so +`lens.andThen(apoScatter)`-style compositions resolve; `Schemes.apoScatter` exposes the +`Left(s) → Done(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. --- From 93f7b3ebca3351f7d699fe847ac008375c963c75 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 4 Sep 2026 17:57:54 +0200 Subject: [PATCH 54/61] docs: CHANGELOG entries for the schemes surface (C4) + plan-doc bibliography pointers (C2) - CHANGELOG [Unreleased]/Added: cats-eo-schemes (the typed zoo, fusion, M-family, paraLens, the removal of the untyped path), core BiAffine (+ assoc matrix row, cross-carrier bridges, Graft, apoScatter), and Basis-in-core with the Plated.fromBasis derivation. - plans 2026-06-09-002 / 2026-06-11-001: point their reference sections at the new bibliography so the branch's citations live in one place. --- CHANGELOG.md | 41 +++++++++++++++++++ ...9-002-feat-typed-recursion-schemes-plan.md | 1 + ...06-11-001-feat-biaffine-scheme-zoo-plan.md | 4 ++ 3 files changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40af65a6..2a72888e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -787,6 +787,47 @@ 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). + +- **`BiAffine` carrier in core — the build-seam decoration carrier.** + `data.BiAffine[A, B]` is `Affine`'s data shape worn on the build seam: + `Step(context, focus)` keeps going, `Done(payload)` means "this slot is + already finished — do not call the coalgebra" (apo grafts by reference, + futu unrolls a prebuilt layer). Ships the forgetful instances, the + `Graft` build-channel accessor, the **composition-matrix row** + (`BiAffine.assoc` same-carrier `andThen` — the build-side mirror of + `Affine.assoc`, `Done` ↔ `Miss` / `Step` ↔ `Hit`) and the cross-carrier + bridges from `Tuple2` (Lens) and `Either` (Prism), so + `lens.andThen(apoScatter)`-style compositions resolve. `Schemes.apoScatter` + exposes the `Left(s) → Done(s)` graft channel as a composable scatter optic. + +- **`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/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 index e32fefdf..9ab2c9bd 100644 --- 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 @@ -558,3 +558,4 @@ B/op recorded against droste basic. - 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 index 14257f5c..b2eef259 100644 --- 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 @@ -435,6 +435,10 @@ in the docs' space-honesty note). ## 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`). From 24a8839ccbfae66011dbd26866b094579d201630 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 4 Sep 2026 19:42:59 +0200 Subject: [PATCH 55/61] =?UTF-8?q?feat(core):=20C8=20=E2=80=94=20break=20th?= =?UTF-8?q?e=20Getter.andThen=203-way=20tie=20for=20Direct-carried=20citiz?= =?UTF-8?q?ens;=20drop=20the=20BiAffine=20clone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C8 — Getter.andThen overload tie: A Direct-carried read-only citizen (the schemes' cata/ana/hylo — Optic[A, Unit, C, Unit, Direct]) matched three Getter andThen overloads at once (the any-carrier member, the re-homed read-only override, the trait's same-carrier inline andThen); dotty called the ranking a three-way draw and getter.andThen(cata) did not compile. Fixed with one new most-specific member on Getter: andThen[C, D](Optic[A, Unit, C, Unit, Direct])(using DummyImplicit): Getter[S, C] — the DummyImplicit makes its parameter-list shape comparable with the other overloads so the strictly-more-specific parameter type wins outright, returning the concrete Getter so ascribed compositions type-check. Pinned by GetterAndThenResolutionSpec (fused-Getter route, other-carrier PickFold route, citizen route); the full CompositionMatrixSpec still passes. BiAffine dropped (review comment from kryptt): data/BiAffine.scala was a field-identical clone of Affine (Done/Step ↔ Miss/Hit) whose map/fold/traverse/assoc/composer instances mirrored Affine's line for line. The build-seam reading of the arms — Miss = finished slot (no coalgebra call), Hit = keep going — is the decoration vocabulary, so it lives on Affine directly: - new Graft[Affine] instance (done = Miss, step = Hit), the injection vocabulary the schemes' build-side citizens construct and consume; - graft-finality laws folded into AffineLaws/AffineTests (finished arm is focus-free, map-inert, folds empty; step carries its focus); - Apo.scatter / Schemes.apoScatter re-carriered onto Affine; - tests: BiAffineSpec -> AffineBuildSeamSpec, ApoScatterSpec updated; - BiAffineLaws/BiAffineTests deleted (folded into the Affine rule set); - docs + CHANGELOG updated; the BiAffine name is left free for a genuinely two-sided-failure carrier if one is ever needed. All gates: root/test 536 green, scalafmt/scalafix clean, mdoc 0 errors. --- CHANGELOG.md | 24 +- .../dev/constructive/eo/data/Affine.scala | 19 ++ .../dev/constructive/eo/data/BiAffine.scala | 224 ------------------ .../dev/constructive/eo/optics/Getter.scala | 19 ++ .../eo/GetterAndThenResolutionSpec.scala | 63 +++++ ...15-001-cleanup-typed-schemes-merge-plan.md | 14 +- .../2026-06-15-typed-schemes-bibliography.md | 8 +- .../eo/laws/data/AffineLaws.scala | 42 +++- .../eo/laws/data/BiAffineLaws.scala | 68 ------ .../eo/laws/data/discipline/AffineTests.scala | 18 +- .../laws/data/discipline/BiAffineTests.scala | 44 ---- .../dev/constructive/eo/schemes/Schemes.scala | 10 +- .../dev/constructive/eo/schemes/zoo/Apo.scala | 42 ++-- .../eo/schemes/ApoScatterSpec.scala | 32 +-- site/docs/schemes.md | 19 +- .../constructive/eo/AffineBuildSeamSpec.scala | 104 ++++++++ .../dev/constructive/eo/BiAffineSpec.scala | 104 -------- .../dev/constructive/eo/OpticsLawsSpec.scala | 33 +-- 18 files changed, 347 insertions(+), 540 deletions(-) delete mode 100644 core/src/main/scala/dev/constructive/eo/data/BiAffine.scala create mode 100644 core/src/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala delete mode 100644 laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala delete mode 100644 laws/src/main/scala/dev/constructive/eo/laws/data/discipline/BiAffineTests.scala create mode 100644 tests/src/test/scala/dev/constructive/eo/AffineBuildSeamSpec.scala delete mode 100644 tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a72888e..2b3b3061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -809,17 +809,23 @@ grep-verified and the one perf-relevant cut B/op-verified: `Plated`/`PSVec`-driven schemes (removed — the erased positional indexing made algebra arity slips a runtime error). -- **`BiAffine` carrier in core — the build-seam decoration carrier.** - `data.BiAffine[A, B]` is `Affine`'s data shape worn on the build seam: - `Step(context, focus)` keeps going, `Done(payload)` means "this slot is - already finished — do not call the coalgebra" (apo grafts by reference, - futu unrolls a prebuilt layer). Ships the forgetful instances, the - `Graft` build-channel accessor, the **composition-matrix row** - (`BiAffine.assoc` same-carrier `andThen` — the build-side mirror of - `Affine.assoc`, `Done` ↔ `Miss` / `Step` ↔ `Hit`) and the cross-carrier +- **`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) → Done(s)` graft channel as a composable scatter optic. + 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 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..5ce7bf7b 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](snd, b) diff --git a/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala b/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala deleted file mode 100644 index 81a4ae49..00000000 --- a/core/src/main/scala/dev/constructive/eo/data/BiAffine.scala +++ /dev/null @@ -1,224 +0,0 @@ -package dev.constructive.eo -package data - -import cats.{Applicative, Monoid} - -import accessor.{Graft, PartialAccessor} -import compose.* -import forgetful.* -import optics.Optic - -/** Carrier for the decoration (`Gather`/`Scatter`) family of the recursion-scheme zoo — - * [[Affine]]'s data shape worn on the *build* seam. Where `Affine.Miss` means "the read found no - * focus", [[BiAffine.Done]] means "**this slot is already finished** — the engine must not call - * the coalgebra for it". Its payload's meaning is pinned per optic value via the existential `A` - * (`Fst[A]`): an apomorphism's `Done` carries a finished subtree (prefill the slot, O(1) graft); a - * futumorphism's `Done` carries a prebuilt layer (unroll it, still no coalgebra call). - * [[BiAffine.Step]] is the keep-going arm: focus `b` alongside a one-F-layer leftover context - * (`Snd[A]`). - * - * Same `Fst` / `Snd` match-type discipline as [[Affine]]: at every constructor site `A` is a - * concrete `Tuple2` (so `Fst[A]` / `Snd[A]` reduce); carried through an `Optic[…, BiAffine]` - * existential, `A` is abstract and the match types stay inert. - * - * The composition-matrix row is shipped: [[BiAffine.assoc]] (same-carrier `andThen`, the - * build-side mirror of [[Affine.assoc]] — `Done` ↔ `Miss`, `Step` ↔ `Hit`) plus the cross-carrier - * bridges [[BiAffine.tuple2biaffine]] (Lens → BiAffine) and [[BiAffine.either2biaffine]] (Prism → - * BiAffine), mirroring `Affine`'s. So `biaffine.andThen(biaffine)` and - * `lens`/`prism`-into-`BiAffine` compositions all resolve. - * - * @tparam A - * existential leftover tuple - * @tparam B - * focus type - */ -sealed trait BiAffine[A, B]: - import BiAffine.* - - /** Monomorphic fold — pattern-match on Done/Step and run the matching branch. - * - * @tparam C - * output type - */ - def fold[C](onDone: Fst[A] => C, onStep: (Snd[A], B) => C): C = this match - case d: Done[A, B] => onDone(d.fst) - case s: Step[A, B] => onStep(s.snd, s.b) - -/** Constructors and typeclass instances for [[BiAffine]]. */ -object BiAffine: - - /** Finished arm — no further building, stores `fst: Fst[A]` directly. `B` is phantom at runtime; - * callers re-typing across a phantom-B change should prefer [[widenB]] over `asInstanceOf`. - */ - final class Done[A, B](val fst: Fst[A]) extends BiAffine[A, B]: - override def toString(): String = s"Done($fst)" - - override def equals(that: Any): Boolean = that match - case other: Done[?, ?] => fst == other.fst - case _ => false - - override def hashCode(): Int = if fst.asInstanceOf[AnyRef] == null then 0 else fst.hashCode - - /** Re-type this `Done[A, B]` as `Done[A, B2]` without allocating a new instance. Safe because - * `Done` stores only `fst: Fst[A]` — the `B` parameter is phantom at the runtime shape. - */ - inline def widenB[B2]: Done[A, B2] = this.asInstanceOf[Done[A, B2]] - - /** Keep-going arm: focus present alongside its one-layer leftover context. */ - final class Step[A, B](val snd: Snd[A], val b: B) extends BiAffine[A, B]: - override def toString(): String = s"Step($snd, $b)" - - override def equals(that: Any): Boolean = that match - case other: Step[?, ?] => snd == other.snd && b == other.b - case _ => false - - override def hashCode(): Int = - (if snd.asInstanceOf[AnyRef] == null then 0 else snd.hashCode) * 31 + - (if b.asInstanceOf[AnyRef] == null then 0 else b.hashCode) - - /** Finished-arm constructor. - * - * @group Constructors - */ - def ofDone[X, B](fst: Fst[X]): BiAffine[X, B] = new Done[X, B](fst) - - /** Keep-going-arm constructor. - * - * @group Constructors - */ - def ofStep[X, B](snd: Snd[X], b: B): BiAffine[X, B] = new Step[X, B](snd, b) - - /** `ForgetfulFunctor[BiAffine]` — maps the focus `B` through the Step arm, passing Done through. - * - * @group Instances - */ - given map: ForgetfulFunctor[BiAffine] with - - def map[X, A, B](fa: BiAffine[X, A], f: A => B): BiAffine[X, B] = fa match - case d: Done[X, A] => new Done[X, B](d.fst) - case s: Step[X, A] => new Step[X, B](s.snd, f(s.b)) - - /** `ForgetfulFold[BiAffine]` — Done empty, Step runs `f` on the focus. - * - * @group Instances - */ - given fold: ForgetfulFold[BiAffine] with - - def foldMap[X, A, M: Monoid](f: A => M, fa: BiAffine[X, A]): M = fa match - case _: Done[X, A] => Monoid[M].empty - case s: Step[X, A] => f(s.b) - - /** `ForgetfulTraverse[BiAffine, Applicative]` — runs `f` on the Step arm, passes Done through via - * `Applicative.pure`. - * - * @group Instances - */ - given traverse: ForgetfulTraverse[BiAffine, Applicative] with - - def traverse[X, A, B, G[_]: Applicative](fa: BiAffine[X, A], f: A => G[B]): G[BiAffine[X, B]] = - fa match - case d: Done[X, A] => Applicative[G].pure(new Done[X, B](d.fst)) - case s: Step[X, A] => Applicative[G].map(f(s.b))(b => new Step[X, B](s.snd, b)) - - /** `PartialAccessor[BiAffine]` — Step has the focus, Done has none. - * - * @group Instances - */ - given partial: PartialAccessor[BiAffine] with - def getOption[X, A](fa: BiAffine[X, A]): Option[A] = fa.fold(_ => None, (_, b) => Some(b)) - - /** `Graft[BiAffine]` — the build-channel injection vocabulary: [[Done]] is the finished arm, - * [[Step]] the keep-going arm. - * - * @group Instances - */ - given graft: Graft[BiAffine] with - def done[X, B](fst: Fst[X]): BiAffine[X, B] = new Done[X, B](fst) - def step[X, B](snd: Snd[X], b: B): BiAffine[X, B] = new Step[X, B](snd, b) - - /** Composition functor for `BiAffine` carriers — the build-side mirror of [[Affine.assoc]] - * (`Done` ↔ `Miss`, `Step` ↔ `Hit`), so the generic `Optic.andThen` resolves for - * `BiAffine`-carried optics. `Z` is identical to Affine's: the outer/inner leftovers nested - * through the `Done`/`Step` arms. `Done` short-circuits (an outer finished slot ends the - * composition); `Step` threads the focus through `inner` and recombines the one-layer contexts. - * - * `Xo` / `Xi` are deliberately unbounded — `BiAffine`'s `Fst` / `Snd` match types stay inert - * when the existential is not a `Tuple`, sound for every concrete optic (each concrete `X` is a - * `Tuple2`), exactly as on [[Affine.assoc]]. - * - * @group Instances - */ - given assoc[Xo, Xi]: AssociativeFunctor[BiAffine, Xo, Xi] with - type Z = (Either[Fst[Xo], (Snd[Xo], Fst[Xi])], (Snd[Xo], Snd[Xi])) - - def composeTo[S, T, A, B, C, D]( - s: S, - outer: Optic[S, T, A, B, BiAffine] { type X = Xo }, - inner: Optic[A, B, C, D, BiAffine] { type X = Xi }, - ): BiAffine[Z, C] = outer.to(s) match - case od: Done[Xo, A] => - new Done[Z, C](Left(od.fst)) - case os: Step[Xo, A] => - inner.to(os.b) match - case id: Done[Xi, C] => - new Done[Z, C](Right((os.snd, id.fst))) - case is: Step[Xi, C] => - new Step[Z, C]((os.snd, is.snd), is.b) - - def composeFrom[S, T, A, B, C, D]( - xd: BiAffine[Z, D], - inner: Optic[A, B, C, D, BiAffine] { type X = Xi }, - outer: Optic[S, T, A, B, BiAffine] { type X = Xo }, - ): T = xd match - case d: Done[Z, D] => - // Fst[Z] = Either[Fst[Xo], (Snd[Xo], Fst[Xi])] — the match-type reduction can't be - // proven at the trait level, so we cast (as on Affine.assoc). - d.fst.asInstanceOf[Either[Fst[Xo], (Snd[Xo], Fst[Xi])]] match - case Left(y) => outer.from(new Done[Xo, B](y)) - case Right((x1, y0)) => - val b: B = inner.from(new Done[Xi, D](y0)) - outer.from(new Step[Xo, B](x1, b)) - case s: Step[Z, D] => - val pair = s.snd.asInstanceOf[(Snd[Xo], Snd[Xi])] - val b: B = inner.from(new Step[Xi, D](pair._2, s.b)) - outer.from(new Step[Xo, B](pair._1, b)) - - /** Lens → BiAffine — the build-side mirror of [[Affine.tuple2affine]]. A `Tuple2` optic has no - * finished arm, so it lifts to an always-`Step` (the `Done` arm is reached only when a - * downstream composition finishes, carrying the rebuilt `T`). Lets a Lens compose with a - * `BiAffine`-carried decoration (e.g. `lens.andThen(apoScatter)`). - * - * @group Instances - */ - given tuple2biaffine: Composer[Tuple2, BiAffine] with - - def to[S, T, A, B](o: Optic[S, T, A, B, Tuple2]): Optic[S, T, A, B, BiAffine] = - new Optic[S, T, A, B, BiAffine]: - type X = (T, o.X) - def to(s: S): BiAffine[X, A] = - val (xo, a) = o.to(s) - new Step[X, A](xo, a) - def from(b: BiAffine[X, B]): T = - b match - case d: Done[X, B] => d.fst - case s: Step[X, B] => o.from((s.snd, s.b)) - - /** Prism → BiAffine — the build-side mirror of [[Affine.either2affine]]. The `Either` - * decomposition maps straight onto `Done` (the `Left` / no-build arm) and `Step` (the `Right` / - * keep-going arm). Lets a Prism compose with a `BiAffine`-carried decoration. - * - * @group Instances - */ - given either2biaffine: Composer[Either, BiAffine] with - - def to[S, T, A, B](o: Optic[S, T, A, B, Either]): Optic[S, T, A, B, BiAffine] = - new Optic[S, T, A, B, BiAffine]: - type X = (o.X, S) - def to(s: S): BiAffine[X, A] = - o.to(s) match - case Right(a) => new Step[X, A](s, a) - case Left(x) => new Done[X, A](x) - def from(xb: BiAffine[X, B]): T = - xb match - case d: Done[X, B] => o.from(Left(d.fst)) - case s: Step[X, B] => o.from(Right(s.b)) 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/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala b/core/src/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala new file mode 100644 index 00000000..f63fd1af --- /dev/null +++ b/core/src/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala @@ -0,0 +1,63 @@ +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/plans/2026-06-15-001-cleanup-typed-schemes-merge-plan.md b/docs/plans/2026-06-15-001-cleanup-typed-schemes-merge-plan.md index caecf2b0..d1e0069d 100644 --- 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 @@ -12,11 +12,13 @@ origin: thread request (kryptt): read the anchor paper, build the bibliography, ## 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: the `BiAffine` carrier in core, +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 the `BiAffine.assoc` matrix row. Tests pass on both JDKs; the plan's +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. @@ -86,7 +88,7 @@ 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 `BiAffine`/`Graft`/`Basis`). +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. @@ -104,11 +106,11 @@ consistent — they were updated in the refactor commits; the page was not). 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 BiAffine bridges. Rewrite the description as: +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; -BiAffine matrix row). Link the bibliography for reviewers who want the papers. +Affine matrix row). Link the bibliography for reviewers who want the papers. ### C7. (non-blocking) `benchmarks` numbers in docs @@ -140,6 +142,6 @@ decision, needs kryptt) → 6. C7 (last, at the merge candidate). - 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, BiAffine matrix row 12→13). + 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 index 52c0d300..b27a65c2 100644 --- a/docs/research/2026-06-15-typed-schemes-bibliography.md +++ b/docs/research/2026-06-15-typed-schemes-bibliography.md @@ -80,7 +80,7 @@ citations of 1103.2841), and the canon the zoo's schemes come from. 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 BiAffine carrier's sub-shape pinning cites. + "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. @@ -123,8 +123,8 @@ in one place. 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 BiAffine's - composition-matrix row targets. + 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 @@ -159,5 +159,5 @@ in one place. (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, BiAffine matrix row, higher-order decoration) land so the citations + 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/BiAffineLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala deleted file mode 100644 index 4af0b2b4..00000000 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/BiAffineLaws.scala +++ /dev/null @@ -1,68 +0,0 @@ -package dev.constructive.eo.laws.data - -import cats.{Applicative, Id} -import dev.constructive.eo.accessor.{Graft, PartialAccessor} -import dev.constructive.eo.data.{BiAffine, Fst, Snd} -import dev.constructive.eo.forgetful.{ForgetfulFold, ForgetfulFunctor, ForgetfulTraverse} - -/** Carrier-level laws for `BiAffine[X, A]` — the decoration carrier of the recursion-scheme zoo - * (`Affine`'s data shape worn on the build seam: `Done` = finished, `Step` = keep going). - * - * Two groups: - * - * - Instance laws mirroring [[AffineLaws]]: `ForgetfulFunctor` identity / composition, - * `ForgetfulTraverse` at `Id`. - * - Graft-channel laws: the `Done` arm is *final* — invisible to the focus (`getOption` empty, - * `foldMap` empty) and inert under `map` — while `Step` carries the focus. These are the - * carrier-shaped halves of D1's "Done is final"; the per-value `graft(Done(t)) == t` equation - * is stated against concrete `Gather`/`Scatter` citizens (which pin `Fst[X]`), not here. - * - * The `AssociativeFunctor[BiAffine]` coherence laws are deliberately absent — the carrier ships - * without its composition-matrix row (follow-up PR), so there is no `andThen` for them to govern. - */ -trait BiAffineLaws[X, A]: - - def functorIdentity(fa: BiAffine[X, A])(using - FF: ForgetfulFunctor[BiAffine] - ): Boolean = - FF.map(fa, identity[A]) == fa - - def functorComposition(fa: BiAffine[X, A], f: A => A, g: A => A)(using - FF: ForgetfulFunctor[BiAffine] - ): Boolean = - FF.map(FF.map(fa, f), g) == FF.map(fa, f.andThen(g)) - - /** `traverse[Id]` is `map` — the degenerate case of the traverse identity law. */ - def traverseIdentity(fa: BiAffine[X, A])(using - FT: ForgetfulTraverse[BiAffine, Applicative] - ): Boolean = - FT.traverse[X, A, A, Id](fa, a => a: Id[A])(using Applicative[Id]) == - fa - - /** The finished arm carries no focus. */ - def doneHasNoFocus(fst: Fst[X])(using - G: Graft[BiAffine], - P: PartialAccessor[BiAffine], - ): 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[BiAffine], - P: PartialAccessor[BiAffine], - ): Boolean = - P.getOption(G.step[X, A](snd, a)).contains(a) - - /** `Done` is inert under `map` — finished means finished. */ - def doneMapInert(fst: Fst[X], f: A => A)(using - G: Graft[BiAffine], - FF: ForgetfulFunctor[BiAffine], - ): Boolean = - FF.map(G.done[X, A](fst), f) == G.done[X, A](fst) - - /** `Done` contributes nothing to a fold. */ - def doneFoldEmpty(fst: Fst[X])(using - G: Graft[BiAffine], - FD: ForgetfulFold[BiAffine], - ): 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/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/BiAffineTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/BiAffineTests.scala deleted file mode 100644 index 56ef883a..00000000 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/BiAffineTests.scala +++ /dev/null @@ -1,44 +0,0 @@ -package dev.constructive.eo.laws.data.discipline - -import cats.Applicative -import dev.constructive.eo.accessor.{Graft, PartialAccessor} -import dev.constructive.eo.data.{BiAffine, Fst, Snd} -import dev.constructive.eo.forgetful.{ForgetfulFold, ForgetfulFunctor, ForgetfulTraverse} -import dev.constructive.eo.laws.data.BiAffineLaws -import org.scalacheck.Prop.forAll -import org.scalacheck.{Arbitrary, Cogen} -import org.typelevel.discipline.Laws - -/** Discipline `RuleSet` for [[BiAffineLaws]]. */ -abstract class BiAffineTests[X, A] extends Laws: - def laws: BiAffineLaws[X, A] - - def biAffine(using - Arbitrary[BiAffine[X, A]], - Arbitrary[A], - Arbitrary[Fst[X]], - Arbitrary[Snd[X]], - Cogen[A], - ForgetfulFunctor[BiAffine], - ForgetfulFold[BiAffine], - ForgetfulTraverse[BiAffine, Applicative], - Graft[BiAffine], - PartialAccessor[BiAffine], - ): RuleSet = - new SimpleRuleSet( - "BiAffine", - "functor identity" -> - forAll((fa: BiAffine[X, A]) => laws.functorIdentity(fa)), - "functor composition" -> - forAll((fa: BiAffine[X, A], f: A => A, g: A => A) => laws.functorComposition(fa, f, g)), - "traverse[Id] identity" -> - forAll((fa: BiAffine[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/src/main/scala/dev/constructive/eo/schemes/Schemes.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala index 78985c4c..25b57aa9 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -3,7 +3,7 @@ package schemes import cats.{~>, Monad, Traverse} -import data.{BiAffine, MultiFocus} +import data.{Affine, MultiFocus} import optics.{GetReplaceLens, Lens, Optic} import optics.Optic.get import zoo.* @@ -145,12 +145,12 @@ object Schemes: 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.BiAffine]] build seam — a composable *scatter* + /** [[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.BiAffine.assoc]] and the [[data.BiAffine.either2biaffine]] bridge; - * it is the carried decoration [[apo]]'s engine itself drives (see [[zoo.Apo]]). + * 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, BiAffine] { type X = (S, Unit) } = + 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`, 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 index 58236c53..6a288cb2 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala @@ -4,7 +4,7 @@ package zoo import cats.Traverse -import data.BiAffine +import data.Affine import optics.Optic /** Apomorphism citizen — an unfold that may **short-circuit with a finished subtree** @@ -16,16 +16,16 @@ import optics.Optic * 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.BiAffine]] optic.''' apo's per-slot decision is exactly `BiAffine`'s - * build seam — `Left(s)` is `Done(s)` (a finished slot, the O(1) graft), `Right(a)` is `Step((), - * a)` (keep unfolding). [[Apo.scatter]] exposes that decision as a composable `BiAffine`-carried + * '''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 → Done/Step → engine`, so apo genuinely speaks the carrier the + * 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 `Done`/`Step` decision is + * `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 `Done(s)` subtree is placed into its result slot **by reference** — the + * '''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 @@ -40,8 +40,8 @@ final class Apo[F[_], A, S](private[zoo] val coalg: A => F[Either[S, A]])(using residual => sc.to(residual) .fold[Either[S, F[Either[S, A]]]]( - s => Left(s), // Done — finished subtree, grafted by reference (O(1)) - (_, a) => Right(coalg(a)), // Step — seed, keep unfolding + s => Left(s), // Miss — finished subtree, grafted by reference (O(1)) + (_, a) => Right(coalg(a)), // Hit — seed, keep unfolding ), fr => E.embed(fr), ) @@ -51,18 +51,18 @@ final class Apo[F[_], A, S](private[zoo] val coalg: A => F[Either[S, A]])(using object Apo: - /** apo's per-slot residual worn on the [[data.BiAffine]] build seam — a *scatter* decoration. - * `Left(s) → Done(s)` (the O(1) graft); `Right(a) → Step((), a)` (keep unfolding). The + /** 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[…, BiAffine]` - * value it composes via [[data.BiAffine.assoc]] and the [[data.BiAffine.either2biaffine]] 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. + * 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, BiAffine] { type X = (S, Unit) } = - new Optic[Either[S, A], Unit, A, Unit, BiAffine]: + 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]): BiAffine[X, A] = e match - case Left(s) => new BiAffine.Done[X, A](s) - case Right(a) => new BiAffine.Step[X, A]((), a) - def from(b: BiAffine[X, Unit]): Unit = () + def to(e: Either[S, A]): Affine[X, A] = e match + case Left(s) => new Affine.Miss[X, A](s) + case Right(a) => new Affine.Hit[X, A]((), a) + def from(b: Affine[X, Unit]): Unit = () diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala index afb936e7..e8bc922c 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala @@ -5,53 +5,53 @@ import scala.language.implicitConversions import org.specs2.mutable.Specification -import data.BiAffine +import data.Affine import optics.Optic import optics.Optic.* // reverseGet import schemes.samples.{Bin, BinF} -/** apo re-carriered onto [[data.BiAffine]]: its per-slot residual is now [[Schemes.apoScatter]], a - * composable `BiAffine`-carried scatter optic (`Left → Done`, the O(1) graft; `Right → Step`, keep +/** 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 - * `Done`/`Step` semantics, that it composes via `BiAffine.assoc`, and that the scheme still builds + * `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 → Done (carrying the grafted subtree)" >> { + "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 → Step (carrying the keep-going focus)" >> { + "apoScatter maps Right → Hit (carrying the keep-going focus)" >> { sc.to(Right(9)).fold(_ => -1, (_, b) => b) === 9 } - // A second BiAffine optic on the focus Int — Done on negatives — to compose under apoScatter. - private val innerToy: Optic[Int, Unit, Int, Unit, BiAffine] { type X = (Int, Unit) } = - new Optic[Int, Unit, Int, Unit, BiAffine]: + // 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): BiAffine[X, Int] = - if n < 0 then new BiAffine.Done[X, Int](n) else new BiAffine.Step[X, Int]((), n) - def from(b: BiAffine[X, Unit]): Unit = () + def to(n: Int): Affine[X, Int] = + if n < 0 then new Affine.Miss[X, Int](n) else new Affine.Hit[X, Int]((), n) + def from(b: Affine[X, Unit]): Unit = () private val composed = sc.andThen(innerToy) - "apoScatter composes via BiAffine.assoc — Step∘Step threads the focus" >> { + "apoScatter composes via Affine.assoc — Hit∘Hit threads the focus" >> { composed.to(Right(5)).fold(_ => -1, (_, b) => b) === 5 } - "apoScatter composes — outer Done (graft) short-circuits the composition" >> { + "apoScatter composes — outer Miss (graft) short-circuits the composition" >> { composed.to(Left(Bin.Leaf(0))).fold(_ => -1, (_, b) => b) === -1 } - "apoScatter composes — inner Done short-circuits the keep-going arm" >> { + "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: Done grafts, Step unfolds" >> { + "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) === diff --git a/site/docs/schemes.md b/site/docs/schemes.md index 228d0e1d..923d5928 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -157,8 +157,8 @@ recursion; the `Plated.fromBasis` derivation is its recursive face, and the recu 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 the -`BiAffine` carrier (see below): +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 | |---|---|---| @@ -309,16 +309,17 @@ val countedLeafSum = Schemes.hyloM[Counted, BinF, Int, Int]( countedLeafSum.get(6).run(0) // (service calls, leaf sum) — one fused pass ``` -### The BiAffine carrier +### The build-seam carrier is `Affine` -The decoration machinery's carrier is new in core: **`BiAffine`** — `Affine`'s data shape worn on -the *build* seam. `Step(context, focus)` keeps going; `Done(payload)` means "this slot is already +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). Its laws are the graft-finality and round-trip equations in `cats-eo-laws`. The -composition-matrix row is shipped: `BiAffine.assoc` (same-carrier `andThen` — `Done` ↔ `Miss`, -`Step` ↔ `Hit`) plus the cross-carrier bridges from `Tuple2` (Lens) and `Either` (Prism), so +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) → Done(s)` graft channel as a composable scatter optic. On the scheme side, +`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. 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..62781ba7 --- /dev/null +++ b/tests/src/test/scala/dev/constructive/eo/AffineBuildSeamSpec.scala @@ -0,0 +1,104 @@ +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, Int](w) + else new Hit[X, Int](List(w), w) + def from(xb: Affine[X, Int]): Int = xb match + case d: Miss[X, Int] => d.fst + case s: Hit[X, Int] => s.b + + "Miss.widenB is allocation-free (reference-equal result)" in { + val d = new Miss[TX, Int](5) + (d.widenB[String].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, Int](-7)) === -7).and(toy.from(new Miss[TX, Int](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, Int](w) else new Hit[X, Int](List(w), w) + def from(xb: Affine[X, Int]): Int = xb match + case d: Miss[X, Int] => 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/BiAffineSpec.scala b/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala deleted file mode 100644 index 0cfbba00..00000000 --- a/tests/src/test/scala/dev/constructive/eo/BiAffineSpec.scala +++ /dev/null @@ -1,104 +0,0 @@ -package dev.constructive.eo - -import org.specs2.mutable.Specification - -import data.BiAffine -import data.BiAffine.{Done, Step} -import optics.Optic - -/** Behaviour checks for the [[BiAffine]] carrier worn by an optic — the graft-finality equations a - * full `Gather`/`Scatter` citizen must satisfy, stated against a toy citizen here (the named - * `Gather`/`Scatter` values 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 `Done` payload is a finished result) and `Snd[X] = F[W]` (the one-F-layer - * leftover context carried by `Step`). - */ -class BiAffineSpec extends Specification: - - private type TX = (Int, List[Int]) - - // Toy full citizen: W = Int, F = List. Negative values are "already finished" - // (Done); non-negative ones keep going, carrying one layer of context. - private val toy: Optic[Int, Int, Int, Int, BiAffine] { type X = TX } = - new Optic[Int, Int, Int, Int, BiAffine]: - type X = TX - def to(w: Int): BiAffine[X, Int] = - if w < 0 then new Done[X, Int](w) - else new Step[X, Int](List(w), w) - def from(xb: BiAffine[X, Int]): Int = xb match - case d: Done[X, Int] => d.fst - case s: Step[X, Int] => s.b - - "Done.widenB is allocation-free (reference-equal result)" in { - val d = new Done[TX, Int](5) - (d.widenB[String].asInstanceOf[AnyRef] eq d.asInstanceOf[AnyRef]) === true - } - - "a full BiAffine citizen" should { - - "treat Done as final: from(Done(w)) == w" in { - (toy.from(new Done[TX, Int](-7)) === -7).and(toy.from(new Done[TX, Int](42)) === 42) - } - - "round-trip the Step 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 Done arm: from(to(w)) == w on finished inputs" in { - List(-1, -100).map(w => toy.from(toy.to(w))) === List(-1, -100) - } - } - - "BiAffine.assoc — the composition-matrix row" should { - - // A second citizen whose Done fires on an *even* focus, so toy.andThen(innerToy) reaches all - // three composed arms: outer Done (w<0), Step∘Step (w≥0 odd), Step∘Done (w≥0 even). - val innerToy: Optic[Int, Int, Int, Int, BiAffine] { type X = TX } = - new Optic[Int, Int, Int, Int, BiAffine]: - type X = TX - def to(w: Int): BiAffine[X, Int] = - if w % 2 == 0 then new Done[X, Int](w) else new Step[X, Int](List(w), w) - def from(xb: BiAffine[X, Int]): Int = xb match - case d: Done[X, Int] => d.fst - case s: Step[X, Int] => s.b - - val composed = toy.andThen(innerToy) - - "biaffine.andThen(biaffine) type-checks and round-trips across all three arms" in { - // w<0 → outer Done; w≥0 odd → Step∘Step; w≥0 even → Step∘Done. - List(-5, 1, 3, 4, 16, 17).map(w => composed.from(composed.to(w))) === - List(-5, 1, 3, 4, 16, 17) - } - - "outer Done short-circuits the composition (Left arm of Z)" in { - composed.from(composed.to(-9)) === -9 - } - } - - "BiAffine cross-carrier bridges" should { - - "Composer[Tuple2, BiAffine] lifts a Lens-shaped optic to always-Step, 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 bi = tupleOptic.morph[BiAffine] - bi.from(bi.to((7, "x"))) === ((7, "x")) - } - - "Composer[Either, BiAffine] maps Right→Step and Left→Done, 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 bi = eitherOptic.morph[BiAffine] - (bi.from(bi.to(Some(5))) === Some(5)).and(bi.from(bi.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 eb1f0d47..18de0275 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala @@ -24,7 +24,7 @@ import optics.{ Traversal, Unfold } -import data.{Affine, BiAffine, Direct, Forget, ModifyF, MultiFocus, PSVec} +import data.{Affine, Direct, Forget, ModifyF, MultiFocus, PSVec} import laws.{ AffineFoldLaws, GetterLaws, @@ -45,8 +45,8 @@ import laws.discipline.{ PrismTests, UnfoldTests } -import laws.data.{AffineLaws, BiAffineLaws, ModifyFLaws} -import laws.data.discipline.{AffineTests, BiAffineTests, ModifyFTests} +import laws.data.{AffineLaws, ModifyFLaws} +import laws.data.discipline.{AffineTests, ModifyFTests} import laws.typeclass.AssociativeFunctorLaws import laws.typeclass.discipline.AssociativeFunctorTests @@ -63,19 +63,6 @@ private given arbAffineIntStringBool: Arbitrary[Affine[(Int, String), Boolean]] ) ) -// Arbitrary[BiAffine[(Int, String), Boolean]] — picks between the finished -// Done arm and the keep-going Step arm with equal weight. -private given arbBiAffineIntStringBool: Arbitrary[BiAffine[(Int, String), Boolean]] = - Arbitrary( - Gen.oneOf( - Arbitrary.arbitrary[Int].map(BiAffine.ofDone[(Int, String), Boolean]), - for - s <- Arbitrary.arbitrary[String] - b <- Arbitrary.arbitrary[Boolean] - yield BiAffine.ofStep[(Int, String), Boolean](s, b), - ) - ) - // Arbitrary[BinF[Int]] — equal-weight leaf / branch layers of the UnfoldSpec pattern functor. private given arbBinFInt: Arbitrary[BinF[Int]] = Arbitrary( @@ -323,16 +310,10 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: .affine, ) - // ----- BiAffine carrier laws ------------------------------------ - // The decoration carrier of the recursion-scheme zoo: instance laws - // plus the graft-channel coherences (Done is final / focus-free). - - checkAll( - "BiAffine[(Int, String), Boolean]", - new BiAffineTests[(Int, String), Boolean]: - val laws = new BiAffineLaws[(Int, String), Boolean] {} - .biAffine, - ) + // ----- 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 ------------------------------------- From b4a44542fa2c2efdb991a00304ee6d4c77deca52 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 4 Sep 2026 22:50:36 +0200 Subject: [PATCH 56/61] perf(schemes): restore para's positional slot-pairing (C7 regression catch); re-pin benchmarks at the merge candidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C7's JMH re-pin on temurin@21 caught a real regression the dedup audit (f1a3266) introduced: Para's combine re-projected each node and materialized its child layer into a List per node (F.toList) — 1 409 788 B/op vs the pinned 557 945, pushing para past droste (1.26x). Fixed by restoring the positional pairing as Machines.foldLayeredSlot (slot-threading sibling of foldLayered whose combine receives the machine's expanded layer + filled slot buffer; heapWalkSlot is the cold-path twin) and rebuildLayerPaired over them. Para now pairs off the layer the machine already holds — no re-project, no per-node List: 557 947 B/op, back to the pinned 0.50x of droste. Also caught by the re-pin: - eoRefoldCross now FUSES: 361 387 B/op, byte-identical to hylo (the C8 overload fix routes ana.cross(cata) to the fused composite — decision 9's design intent; FusionSpec's no-intermediate-S witness covers it). The materializing contrast is the hand-written eoRefoldManual (885 589). - benchmarks module had drifted off the shipped API (2-arg algebras, removed Gather/Scatter vocabulary, removed proto package): fixed SchemesBench/fixtures to 1-arg node-blind algebras, dropped the generic-route row with the vocabulary, deleted ProtoFusionBench (proto spike carrier is gone). site/docs/benchmarks.md tables re-pinned to the merge-candidate sweep (temurin@21, -f 1 -i 5 -wi 3); the two changed rows and their bullets re-scoped (para regression + fix noted; cross-fusion bullet rewritten; droste histo/futu re-measured). --- .../eo/bench/ProtoFusionBench.scala | 55 ------------------- .../constructive/eo/bench/SchemesBench.scala | 12 +--- .../eo/bench/fixture/SchemesFixtures.scala | 34 ++++-------- .../eo/GetterAndThenResolutionSpec.scala | 4 +- .../constructive/eo/schemes/Machines.scala | 47 ++++++++++++++-- .../constructive/eo/schemes/zoo/Para.scala | 12 ++-- site/docs/benchmarks.md | 39 +++++++------ 7 files changed, 85 insertions(+), 118 deletions(-) delete mode 100644 benchmarks/src/main/scala/dev/constructive/eo/bench/ProtoFusionBench.scala diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/ProtoFusionBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/ProtoFusionBench.scala deleted file mode 100644 index 3587ec65..00000000 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/ProtoFusionBench.scala +++ /dev/null @@ -1,55 +0,0 @@ -package dev.constructive.eo -package bench - -import org.openjdk.jmh.annotations.* -import java.util.concurrent.TimeUnit - -import dev.constructive.eo.bench.fixture.* -import dev.constructive.eo.bench.fixture.SchemesFixtures.given -import dev.constructive.eo.optics.Optic.* -import dev.constructive.eo.schemes.Schemes -import dev.constructive.eo.schemes.proto.{Proto, Scheme} -import Scheme.given - -/** Prototype validation: does `ana.cross(cata)` on the `Scheme` carrier fuse to `hylo` cost? - * - * - `protoFusedCross` — `ana.cross(cata)` with a node-BLIND `cata` (X = Nothing): should rebuild - * the single-pass hylo machine — NO intermediate `Bin` — so ≈ `eoHyloRef` (≈361k B/op). - * - `protoParaCross` — `ana.cross(para)` with a node-READING `para` (X = S): the overload picks - * the materialising branch (build the `Bin`, then fold) — ≈885k B/op. - * - `protoManual` — `cata.get(ana.reverseGet(...))`, the hand-written materialisation — ≈885k. - * - * The X-resolution (Nothing vs S) is what selects fused vs materialising — same `cross` spelling. - */ -@State(Scope.Benchmark) -@BenchmarkMode(Array(Mode.AverageTime)) -@OutputTimeUnit(TimeUnit.NANOSECONDS) -@Fork(3) -@Warmup(iterations = 3, time = 1) -@Measurement(iterations = 5, time = 1) -class ProtoFusionBench extends JmhDefaults: - import SchemesFixtures.* - - final val Depth = 12 // 2^12 = 4096 leaves, 8191 nodes — same workload as SchemesBench - - // node-BLIND fold (true catamorphism) — fusable - private val pureSum: BinF[Int] => Int = { - case BinF.LeafF(v) => v - case BinF.NodeF(l, r) => l + r - } - - // Prebuilt optics (construction not measured). - val protoCata = Proto.cata[BinF, Bin, Int](pureSum) - val protoPara = Proto.para[BinF, Bin, Int](eoTypedSum) // node-READING (X = S) - val protoAna = Proto.ana[BinF, Int, Bin](eoTypedCoalg) - - val protoFusedG = protoAna.cross(protoCata) // X_cata = Nothing → fused - val protoParaG = protoAna.cross(protoPara) // X_para = S → materialising - - // Reference: the existing fused hylo (node-blind), the bar to hit. - val eoHyloRefG = Schemes.hylo[BinF, Int, Int](eoTypedCoalg, (_, fa) => pureSum(fa)) - - @Benchmark def protoFusedCross: Int = protoFusedG.get(Depth) - @Benchmark def protoParaCross: Int = protoParaG.get(Depth) - @Benchmark def protoManual: Int = protoCata.get(protoAna.reverseGet(Depth)) - @Benchmark def eoHyloRef: Int = eoHyloRefG.get(Depth) 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 41b0ca4b..0447c3bf 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -120,15 +120,9 @@ class SchemesBench extends JmhDefaults: @Benchmark def eoRefoldCross: Int = eoRefoldCrossG.get(Depth) @Benchmark def eoRefoldManual: Int = eoCataG.get(eoAnaR.reverseGet(Depth)) - // ----- generic decoration route (user-written Gather, no identity fast path) -- - - val eoCataGenericG = Schemes.cata[BinF, Bin, Int, Int](userIdGather)(eoTypedSum) - - @Benchmark def eoCataGenericRoute: Int = eoCataGenericG.get(eoTree) - // ----- the M path at Id: the tailRecM-lifted machine's per-event floor ------ - val eoHyloMRunner = - Schemes.hyloM[cats.Id, BinF, Int, Int](d => eoTypedCoalg(d), (s, fa) => eoTypedHyloAlg(s, fa)) + val eoHyloMRunner = Schemes.hyloM[cats.Id, BinF, Int, Int](eoTypedCoalg, fa => + eoTypedHyloAlg(fa)) - @Benchmark def eoHyloM: Int = eoHyloMRunner.run(Depth) + @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 c194648a..e13e1520 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 @@ -71,11 +71,9 @@ object SchemesFixtures: // ----- eo TYPED algebras (over the pattern functor BinF via Basis/Traverse) ---------------- - /** Typed cata gather — the leaf-sum, pattern-matching `BinF`'s named constructors. */ - val eoTypedSum: (Bin, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(v) => v - case BinF.NodeF(l, r) => l + r + /** 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 } /** Typed coalgebra (the single fused `Seed => F[Seed]` shape) — builds the perfect binary tree. */ @@ -83,10 +81,8 @@ object SchemesFixtures: if d <= 0 then BinF.LeafF(1) else BinF.NodeF(d - 1, d - 1) /** Typed fused-hylo algebra — folds to `Int` directly, never building a `Bin`. */ - val eoTypedHyloAlg: (Int, BinF[Int]) => Int = (_, fa) => - fa match - case BinF.LeafF(_) => 1 - case BinF.NodeF(l, r) => l + r + 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) -- @@ -113,14 +109,12 @@ object SchemesFixtures: 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, Gather} + 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: (Bin, BinF[(Bin, Int)]) => Int = (_, fa) => - fa match - case BinF.LeafF(v) => v - case BinF.NodeF((_, l), (_, r)) => l + r + 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 @@ -136,10 +130,8 @@ object SchemesFixtures: } // histo, heads only: the course-of-value bookkeeping cost. - val eoHistoAlg: (Bin, BinF[EoAttr[BinF, Int]]) => Int = (_, fa) => - fa match - case BinF.LeafF(v) => v - case BinF.NodeF(l, r) => l.head + r.head + 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 @@ -156,9 +148,3 @@ object SchemesFixtures: else BinF.NodeF(DCoattr.pure(d - 1), DCoattr.pure(d - 1)) } - // generic decoration route: a USER-WRITTEN id gather (not the Gather.cata - // singleton, so the driver cannot take the identity fast path) — D4's - // dispatch-cost honesty number. - val userIdGather: Gather[BinF, Int, Int] = - new Gather[BinF, Int, Int]: - def gather(layer: BinF[Int], a: Int): Int = a diff --git a/core/src/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala b/core/src/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala index f63fd1af..78d04292 100644 --- a/core/src/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala +++ b/core/src/test/scala/dev/constructive/eo/GetterAndThenResolutionSpec.scala @@ -24,6 +24,7 @@ class GetterAndThenResolutionSpec extends Specification: 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) @@ -43,7 +44,8 @@ class GetterAndThenResolutionSpec extends Specification: } "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 + 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")) } diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala index ca66cec7..84739faa 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -158,6 +158,24 @@ private[schemes] object Machines: * 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 — no per-node + * re-`project` and no per-node `List` materialization (both cost ~82 B/node on the 8 191-node + * fixture — the `F.toList` route is what the dedup audit briefly shipped and the C7 re-pin + * caught: para regressed 557 945 → 1 409 788 B/op, past droste). + */ + private[schemes] 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] = @@ -183,6 +201,16 @@ private[schemes] object Machines: 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 = @@ -190,7 +218,7 @@ private[schemes] object Machines: 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, rebuildLayer(layer, slots)), stack) + 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 @@ -199,7 +227,7 @@ private[schemes] object Machines: 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, rebuildLayer(fr.layer, fr.slots)), rest) + else loop(Ascend, combine(fr.node, fr.layer, fr.slots), rest) op match case Ascend => bubble @@ -218,9 +246,20 @@ private[schemes] object Machines: 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]] handing the combine the machine's raw pieces — the expanded layer and the + * already-filled slot buffer, WITHOUT pre-building `F[R]` (the subterm-retaining engines + * ([[zoo.Para]]) pair `layer` + `slots` via [[rebuildLayerPaired]] and never need the rebuilt + * layer; engines that do call [[rebuildLayer]] themselves). Same walk, same stack-safety. + */ + private[schemes] 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 heapWalk(n, m => Right(expand(m)), combine) + if depth >= OnStackLimit then heapWalkSlot(n, m => Right(expand(m)), combine) else val layer = expand(n) val slots = childrenSlots[F, N, R](layer) @@ -228,7 +267,7 @@ private[schemes] object Machines: while i < slots.length do slots(i) = rec(childAt(slots(i)), depth + 1) i += 1 - combine(n, rebuildLayer(layer, slots)) + combine(n, layer, slots) n => rec(n, 0) 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 index e06c2962..7634d39e 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala @@ -28,12 +28,14 @@ final class Para[F[_], S, A](private[zoo] val alg: F[(S, A)] => A)(using type X = F[(S, A)] private val run: S => A = - Machines.foldLayered[F, S, A]( + Machines.foldLayeredSlot[F, S, A]( P.project, - (s, fa) => - // pair each child's original subterm (re-projected) with its folded result, in order. - val it = F.toList(fa).iterator - alg(F.map(P.project(s))(sub => (sub, it.next()))), + (_, layer, slots) => + // pair each child's original subterm with its folded result — positionally, off the + // layer the machine already expanded and its own slot buffer. No re-project, no + // per-node `List` (the C7 re-pin: the toList+re-project route cost ~+262k B/op on the + // 8 191-node fixture, pushing para past droste). + alg(Machines.rebuildLayerPaired(layer, slots)), ) protected def read(s: S): A = run(s) diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index 6eae2547..0f8724d0 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -453,9 +453,9 @@ runner): | `drosteCata` | 164 824 | 1× | | `drosteHylo` | 328 641 | 1× | | `drosteAna` | 327 632 | 1× | -| `eoCata` | 361 385 | 2.2× | -| `eoHylo` | 361 385 | 1.1× | -| `eoAna` | 524 193 | 1.6× | +| `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 @@ -472,23 +472,22 @@ As above, B/op is the trustworthy column; ns/op is directional. | Method | ns/op | B/op | B/op vs droste | |---|--:|--:|--:| -| `eoPara` | 183 747 | 557 945 | 0.50× | -| `drostePara` | 283 752 | 1 114 890 | 1× | -| `eoApo` | 195 677 | 655 249 | 0.57× | -| `drosteApo` | 293 721 | 1 146 674 | 1× | -| `eoApoGraft` | 35 | 224 | 0.88× | -| `drosteApoGraft` | 46 | 256 | 1× | -| `eoHisto` | 191 617 | 557 969 | 1.24× | -| `drosteHisto` | 103 420 | 448 705 | 1× | -| `eoFutu` | 188 749 | 655 249 | 1.43× | -| `drosteFutu` | 93 458 | 458 689 | 1× | -| `eoCata` | 172 428 | 361 385 | 2.19× | -| `eoCataGenericRoute` | 162 279 | 362 313 | 2.20× | -| `drosteCata` | 56 542 | 164 824 | 1× | -| `eoHylo` | 180 767 | 361 385 | — | -| `eoHyloM` | 303 295 | 820 298 | — | -| `eoRefoldCross` | — | 885 577 | — | -| `eoRefoldManual` | 375 824 | 885 579 | — | +| `eoPara` | 505 572 | 557 947 | 0.50× | +| `drostePara` | 311 608 | 1 114 890 | 1× | +| `eoApo` | 291 684 | 655 250 | 0.68× | +| `drosteApo` | 577 488 | 969 860 | 1× | +| `eoApoGraft` | 63 | 280 | 1.17× | +| `drosteApoGraft` | 85 | 240 | 1× | +| `eoHisto` | 246 582 | 557 970 | 1.54× | +| `drosteHisto` | 78 207 | 361 409 | 1× | +| `eoFutu` | 279 162 | 655 250 | 1.25× | +| `drosteFutu` | 82 249 | 524 161 | 1× | +| `eoCata` | 311 624 | 361 386 | 2.19× | +| `drosteCata` | 53 295 | 164 824 | 1× | +| `eoHylo` | 311 923 | 361 386 | — | +| `eoHyloM` | 379 135 | 820 299 | — | +| `eoRefoldCross` | 384 525 | 361 387 | — | +| `eoRefoldManual` | 1 834 386 | 885 589 | — | Six results: From 75d44a439ba3eb88ced5520ebc2465f8c761b72d Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 4 Sep 2026 23:26:37 +0200 Subject: [PATCH 57/61] =?UTF-8?q?fix(schemes):=20adapt=20to=20the=20rebase?= =?UTF-8?q?d=20base=20=E2=80=94=20Affine.Miss[A]=20arity,=20widened=20upca?= =?UTF-8?q?st,=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrent force-push rebased the branch onto a newer main (#26, Unfold): Miss dropped its second type parameter (Miss[A] extends Affine[A, Nothing], re-typing subsumed by subtyping, widenB gone). Adapts the C8/BiAffine-drop commit's touched files to that arity — Graft[Affine] constructors, Apo.scatter, the spec toys (widenB check rewritten as the allocation-free upcast it now is) — plus scalafmt on the drifted files. --- .../dev/constructive/eo/data/Affine.scala | 2 +- .../dev/constructive/eo/optics/Plated.scala | 4 +--- .../dev/constructive/eo/schemes/zoo/Apo.scala | 2 +- .../eo/schemes/ApoScatterSpec.scala | 3 +-- .../constructive/eo/schemes/ChronoSpec.scala | 1 - .../constructive/eo/schemes/FusionSpec.scala | 1 - .../constructive/eo/schemes/MetaSpec.scala | 1 - .../eo/schemes/SchemesMSpec.scala | 1 - .../constructive/eo/schemes/SchemesSpec.scala | 1 - .../eo/schemes/ZooExtendedSpec.scala | 1 - .../dev/constructive/eo/schemes/ZooSpec.scala | 1 - .../eo/schemes/ZooTowersSpec.scala | 1 - .../constructive/eo/AffineBuildSeamSpec.scala | 21 ++++++++++--------- 13 files changed, 15 insertions(+), 25 deletions(-) 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 5ce7bf7b..de8d86ef 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Affine.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Affine.scala @@ -237,4 +237,4 @@ object Affine: */ 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](snd, b) + 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/Plated.scala b/core/src/main/scala/dev/constructive/eo/optics/Plated.scala index 46660794..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,9 +3,7 @@ package optics import scala.annotation.tailrec -import cats.Traverse - -import cats.Eval +import cats.{Eval, Traverse} import java.util.ArrayDeque import data.{ModifyF, MultiFocus, PSVec} 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 index 6a288cb2..86963b41 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Apo.scala @@ -63,6 +63,6 @@ object Apo: 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, A](s) + 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/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala index e8bc922c..090d6e14 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ApoScatterSpec.scala @@ -8,7 +8,6 @@ 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 @@ -34,7 +33,7 @@ class ApoScatterSpec extends Specification: 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, Int](n) else new Affine.Hit[X, Int]((), n) + 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) diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala index 46303073..5d169d5b 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ChronoSpec.scala @@ -6,7 +6,6 @@ import scala.language.implicitConversions import org.specs2.mutable.Specification import optics.Optic.* // get, reverseGet - import schemes.samples.{Bin, BinF} import schemes.zoo.{Attr, Coattr} diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala index f1fff408..faab993e 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/FusionSpec.scala @@ -6,7 +6,6 @@ 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 diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala index 19efb197..3b921626 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/MetaSpec.scala @@ -6,7 +6,6 @@ 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} diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala index 9739bf60..baadb5db 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesMSpec.scala @@ -8,7 +8,6 @@ import cats.{Eval, Id} import org.specs2.mutable.Specification import optics.Optic.* // get, reverseGet - import schemes.samples.{Bin, BinF} import schemes.zoo.{Attr, Coattr} 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 1d76affe..fd1a2a9b 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -8,7 +8,6 @@ import org.specs2.mutable.Specification 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`. diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala index d91e7adc..2b5fe6fd 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooExtendedSpec.scala @@ -6,7 +6,6 @@ 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} diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala index 28625414..3d3c1b8f 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooSpec.scala @@ -6,7 +6,6 @@ import scala.language.implicitConversions import org.specs2.mutable.Specification import optics.Optic.* // get, reverseGet - import schemes.samples.{Bin, BinF} import schemes.zoo.{Attr, Coattr} diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala index b99d43dd..f1c20d25 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ZooTowersSpec.scala @@ -8,7 +8,6 @@ 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 diff --git a/tests/src/test/scala/dev/constructive/eo/AffineBuildSeamSpec.scala b/tests/src/test/scala/dev/constructive/eo/AffineBuildSeamSpec.scala index 62781ba7..e5e81051 100644 --- a/tests/src/test/scala/dev/constructive/eo/AffineBuildSeamSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/AffineBuildSeamSpec.scala @@ -24,21 +24,22 @@ class AffineBuildSeamSpec extends Specification: new Optic[Int, Int, Int, Int, Affine]: type X = TX def to(w: Int): Affine[X, Int] = - if w < 0 then new Miss[X, Int](w) + 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, Int] => d.fst - case s: Hit[X, Int] => s.b + case d: Miss[X] => d.fst + case s: Hit[X, Int] => s.b - "Miss.widenB is allocation-free (reference-equal result)" in { - val d = new Miss[TX, Int](5) - (d.widenB[String].asInstanceOf[AnyRef] eq d.asInstanceOf[AnyRef]) === true + "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, Int](-7)) === -7).and(toy.from(new Miss[TX, Int](42)) === 42) + (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 { @@ -58,10 +59,10 @@ class AffineBuildSeamSpec extends Specification: 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, Int](w) else new Hit[X, Int](List(w), w) + 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, Int] => d.fst - case s: Hit[X, Int] => s.b + case d: Miss[X] => d.fst + case s: Hit[X, Int] => s.b val composed = toy.andThen(innerToy) From 2d854050aa47e40bd98cfd54003bfd6a55b634d9 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Fri, 4 Sep 2026 23:29:32 +0200 Subject: [PATCH 58/61] style(benchmarks): scalafmt the re-pinned sources --- .../constructive/eo/bench/SchemesBench.scala | 3 +-- .../eo/bench/fixture/SchemesFixtures.scala | 17 ++++++++--------- 2 files changed, 9 insertions(+), 11 deletions(-) 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 0447c3bf..d6b26ff6 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SchemesBench.scala @@ -122,7 +122,6 @@ class SchemesBench extends JmhDefaults: // ----- 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)) + 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 e13e1520..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 @@ -72,8 +72,7 @@ object SchemesFixtures: // ----- eo TYPED algebras (over the pattern functor BinF via Basis/Traverse) ---------------- /** 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 } + val eoTypedSum: BinF[Int] => Int = { case BinF.LeafF(v) => v; case BinF.NodeF(l, r) => l + r } /** Typed coalgebra (the single fused `Seed => F[Seed]` shape) — builds the perfect binary tree. */ @@ -81,8 +80,7 @@ object SchemesFixtures: if d <= 0 then BinF.LeafF(1) else BinF.NodeF(d - 1, d - 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 } + 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) -- @@ -113,8 +111,9 @@ object SchemesFixtures: // 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 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 @@ -130,8 +129,9 @@ object SchemesFixtures: } // 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 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 @@ -147,4 +147,3 @@ object SchemesFixtures: if d <= 0 then BinF.LeafF(1) else BinF.NodeF(DCoattr.pure(d - 1), DCoattr.pure(d - 1)) } - From 40330a46e367901ea1a7a4abe00aaafdbeec7ee3 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 1 Oct 2026 00:05:18 +0200 Subject: [PATCH 59/61] fix(schemes): para rides a typed paired engine; docs re-pinned without the phantom ns regression The C7 fix restored para's positional subterm pairing through a slot-level engine entry that handed the raw Array[Slot[N, R]] to the combine - contradicting Machines' documented invariant (the raw Slot buffer never leaves the engine) - and Para's own scaladoc still described the audited re-project route. - Machines: new `foldLayeredPaired` - the typed sibling of `foldLayered`, whose combine receives the node's layer with each child paired with its folded result (`F[(N, R)]`): para's shape, with no re-project and no per-node List. `foldLayeredSlot` and `rebuildLayerPaired` drop to `private`, so the slot buffer stays inside the file and the two `foldLayered*` drivers are the typed surface. Same walk, same per-node work - the pinned para B/op is unchanged by construction. - Para: wired to `foldLayeredPaired`; scaladoc states the actual route. - docs(benchmarks): the zoo table drops the ns/op column - B/op is the gate metric, and the mixed single-fork ns column showed a para "time regression" that focused runs do not reproduce. Stale results rewritten to the re-pinned numbers: para 0.50x, apo 0.68x, histo 1.54x, futu 1.25x, graft 280 vs 240, and `ana.cross(cata)` fusing (byte-identical to `hylo`) with the materialising spelling named as the manual pair. The Gather/Scatter generic-route bullet and the removed `proto` spike reference are gone. --- .../constructive/eo/schemes/Machines.scala | 33 +++++-- .../constructive/eo/schemes/zoo/Para.scala | 17 +--- site/docs/benchmarks.md | 91 +++++++++---------- 3 files changed, 73 insertions(+), 68 deletions(-) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala index 84739faa..6a46b01b 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -160,12 +160,12 @@ private[schemes] object Machines: */ /** [[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 — no per-node - * re-`project` and no per-node `List` materialization (both cost ~82 B/node on the 8 191-node - * fixture — the `F.toList` route is what the dedup audit briefly shipped and the C7 re-pin - * caught: para regressed 557 945 → 1 409 788 B/op, past droste). + * `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[schemes] def rebuildLayerPaired[F[_], N, R](fn: F[N], out: Array[Slot[N, R]])(using + 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) @@ -248,12 +248,25 @@ private[schemes] object Machines: )(using F: Traverse[F]): N => R = foldLayeredSlot(expand, (n, layer, slots) => combine(n, rebuildLayer(layer, slots))) - /** [[foldLayered]] handing the combine the machine's raw pieces — the expanded layer and the - * already-filled slot buffer, WITHOUT pre-building `F[R]` (the subterm-retaining engines - * ([[zoo.Para]]) pair `layer` + `slots` via [[rebuildLayerPaired]] and never need the rebuilt - * layer; engines that do call [[rebuildLayer]] themselves). Same walk, same stack-safety. + /** [[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 foldLayeredSlot[F[_], N, R]( + 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 = 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 index 7634d39e..f5cba82d 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala @@ -17,9 +17,10 @@ import cats.Traverse * conditional, not free. This citizen ships the unconditionally-sound read; the writable put is a * scoped follow-up rather than an asserted capability. * - * Subterms are recovered by re-`project`ing each node (one extra peel per node) and zipping with - * the children's results in `Foldable` order — sound for any lawful `Traverse`. Stack-safe (the - * [[Machines.foldLayered]] machine). + * 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], @@ -28,14 +29,6 @@ final class Para[F[_], S, A](private[zoo] val alg: F[(S, A)] => A)(using type X = F[(S, A)] private val run: S => A = - Machines.foldLayeredSlot[F, S, A]( - P.project, - (_, layer, slots) => - // pair each child's original subterm with its folded result — positionally, off the - // layer the machine already expanded and its own slot buffer. No re-project, no - // per-node `List` (the C7 re-pin: the toList+re-project route cost ~+262k B/op on the - // 8 191-node fixture, pushing para past droste). - alg(Machines.rebuildLayerPaired(layer, slots)), - ) + Machines.foldLayeredPaired[F, S, A](P.project, (_, paired) => alg(paired)) protected def read(s: S): A = run(s) diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index 0f8724d0..23d74fbf 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -437,14 +437,14 @@ macro — but that emits a *function*, not an `Optic`, which would break the ## Recursion schemes — the typed path vs droste and hand-written `SchemesBench` measures the typed recursion schemes (`cata` / `ana` / `hylo` and the -zoo — the `foldLayered` `ArrayDeque` machine, stack-safe to 10⁶ nodes) against +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 (runs 27398242244 + 27445302118, 2026-06-12/13 — every eo row byte-identical across the two except the optimised `eoHyloM`; B/op is the trustworthy metric on the shared -runner): +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) | |---|--:|--:| @@ -466,63 +466,62 @@ numbers follow. 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 generic -decoration route, the monadic machine at `cats.Id`, and the materialising `cross` vs fused `hylo`. -As above, B/op is the trustworthy column; ns/op is directional. +`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 | ns/op | B/op | B/op vs droste | -|---|--:|--:|--:| -| `eoPara` | 505 572 | 557 947 | 0.50× | -| `drostePara` | 311 608 | 1 114 890 | 1× | -| `eoApo` | 291 684 | 655 250 | 0.68× | -| `drosteApo` | 577 488 | 969 860 | 1× | -| `eoApoGraft` | 63 | 280 | 1.17× | -| `drosteApoGraft` | 85 | 240 | 1× | -| `eoHisto` | 246 582 | 557 970 | 1.54× | -| `drosteHisto` | 78 207 | 361 409 | 1× | -| `eoFutu` | 279 162 | 655 250 | 1.25× | -| `drosteFutu` | 82 249 | 524 161 | 1× | -| `eoCata` | 311 624 | 361 386 | 2.19× | -| `drosteCata` | 53 295 | 164 824 | 1× | -| `eoHylo` | 311 923 | 361 386 | — | -| `eoHyloM` | 379 135 | 820 299 | — | -| `eoRefoldCross` | 384 525 | 361 387 | — | -| `eoRefoldManual` | 1 834 386 | 885 589 | — | - -Six results: - -- **`para` / `apo` halve droste's allocation.** eo decorates on the same array machine as - `cata`/`ana`, pairing subterms off the already-walked nodes; droste's zoo re-embeds each - subterm (para) and re-allocates the `Either` spine (apo), landing at ~2× eo's B/op - (1 114 890 vs 557 945; 1 146 674 vs 655 249). The ns column agrees directionally - (~1.5× in eo's favour on both). +| 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 (224 vs 256), + 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`. -- **The generic decoration route costs nothing.** A user-written identity gather — which skips - the driver's identity fast path — lands at 362 313 B/op vs the fast path's 361 385: escape - analysis elides the per-node decoration wrapper, so writing your own `Gather`/`Scatter` route is - alloc-free over `cata`. -- **`histo` / `futu` trail droste by ~1.2–1.4× B/op — the price of stack-safety.** The remaining +- **`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 298 B/op vs 361 385 for `hylo` (~2.3×) — that delta is the `tailRecM` step-event + 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)` materialises; `hylo` is the fusion.** `ana` is a build-only `Review` and - `cata` a read-only `Getter` (duals over `Direct`); their `cross` is the build⇄read seam, which - builds the whole `Bin` then folds it — `eoRefoldCross` (885 577 B/op) is byte-identical to the - hand-written `eoRefoldManual` `cata.get(ana.reverseGet(…))` (885 579). The fused, no-intermediate - spelling is `hylo` (361 385 B/op, ~2.4× less). Recovering hylo cost *through* `cross` needs the - optic to carry its (co)algebra — see the `proto` spike (X-indexed `Scheme` carrier), where a - node-blind `cata` makes `ana.cross(cata)` fuse back to 361 386 B/op. +- **`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 From 82362794fe4208774fe19cb1219e2b0d7a60ceed Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 1 Oct 2026 00:05:26 +0200 Subject: [PATCH 60/61] test(schemes): pin para's route - one peel per node (the regression witness) ParaRouteSpec counts `project` calls over a 7-node tree: a para fold must peel each node exactly once (7), because the retained subterms come off the layer the machine already peeled. The audited route - re-`project`ing each node, or materializing its children into a List - peels every node twice and allocates an extra layer per node, which is what let para's B/op climb past droste unnoticed. Second case pins that the algebra really reads the subterms (a left-leaf-weighted sum a plain cata cannot express). --- .../eo/schemes/ParaRouteSpec.scala | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 schemes/src/test/scala/dev/constructive/eo/schemes/ParaRouteSpec.scala 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..99f4f342 --- /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 + } From d703be5bbcc45aa4a68a36c0e041a30ee1fa215e Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 1 Oct 2026 00:09:13 +0200 Subject: [PATCH 61/61] style(schemes): scalafmt the para-engine sources after the rebase --- .../dev/constructive/eo/schemes/Machines.scala | 14 +++++++------- .../dev/constructive/eo/schemes/zoo/Para.scala | 8 ++++---- .../constructive/eo/schemes/ParaRouteSpec.scala | 8 ++++---- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala index 6a46b01b..4d6eebdf 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Machines.scala @@ -161,9 +161,8 @@ private[schemes] object Machines: /** [[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. + * 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] @@ -249,10 +248,11 @@ private[schemes] object Machines: 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`). + * 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], 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 index f5cba82d..94f5f10a 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/zoo/Para.scala @@ -17,10 +17,10 @@ import cats.Traverse * 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). + * 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], diff --git a/schemes/src/test/scala/dev/constructive/eo/schemes/ParaRouteSpec.scala b/schemes/src/test/scala/dev/constructive/eo/schemes/ParaRouteSpec.scala index 99f4f342..bb829bd3 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/ParaRouteSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/ParaRouteSpec.scala @@ -9,10 +9,10 @@ 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`). + * 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: