diff --git a/README.md b/README.md index 36edaaa3..0c0c4ab3 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ personStreet.modify(_.toUpperCase)(alice) // address.street := "MAIN ST" a read-only `Optional`; partial projection without a write side. - [`Getter`](https://eo.constructive.dev/optics.html#getter) — a read-only one-focus projection. -- [`Setter`](https://eo.constructive.dev/optics.html#setter) — a +- [`Modify`](https://eo.constructive.dev/optics.html#modify) — a write-only optic; modify without observing. - [`Fold`](https://eo.constructive.dev/optics.html#fold) — N foci summarised through a `Monoid`. @@ -76,24 +76,27 @@ personStreet.modify(_.toUpperCase)(alice) // address.street := "MAIN ST" whole structure. - [`Review`](https://eo.constructive.dev/optics.html#review) — the reverse-only half of a `Prism`; build, never observe. -- [`JsonPrism`](https://eo.constructive.dev/circe.html#jsonprism) — +- [`Unfold`](https://eo.constructive.dev/optics.html#unfold) — the + build-only many optic (`embed: F[B] => T`); assemble one whole from + an `F`-layer of parts — the algebra of a recursion scheme. +- [`JsonPrism`](https://eo.constructive.dev/integrations/circe.html#jsonprism) — cursor-backed JSON optic with observable-by-default `Ior` failures. -- [`JsonFieldsPrism`](https://eo.constructive.dev/circe.html#multi-field-focus----fields-_a-_b) — +- [`JsonFieldsPrism`](https://eo.constructive.dev/integrations/circe.html#multi-field-focus----fields-_a-_b) — multi-field flavour of `JsonPrism`. -- [`JsonTraversal`](https://eo.constructive.dev/circe.html#jsontraversal-each) — +- [`JsonTraversal`](https://eo.constructive.dev/integrations/circe.html#jsontraversal-each) — `.each` traversal across JSON arrays. -- [`JsonFieldsTraversal`](https://eo.constructive.dev/circe.html#multi-field-focus----fields-_a-_b) — +- [`JsonFieldsTraversal`](https://eo.constructive.dev/integrations/circe.html#multi-field-focus----fields-_a-_b) — multi-field flavour of `JsonTraversal`. -- [`AvroPrism`](https://eo.constructive.dev/avro.html) — +- [`AvroPrism`](https://eo.constructive.dev/integrations/avro.html) — schema-aware Avro optic over `IndexedRecord`, with the same `Ior` failure surface as `JsonPrism` plus a `.union[Branch]` macro for Avro union types. Triple input — parsed record, binary wire bytes, or Avro JSON. -- [`AvroFieldsPrism`](https://eo.constructive.dev/avro.html) — multi- +- [`AvroFieldsPrism`](https://eo.constructive.dev/integrations/avro.html) — multi- field flavour of `AvroPrism`. -- [`AvroTraversal`](https://eo.constructive.dev/avro.html) — `.each` +- [`AvroTraversal`](https://eo.constructive.dev/integrations/avro.html) — `.each` traversal across Avro arrays. -- [`AvroFieldsTraversal`](https://eo.constructive.dev/avro.html) — +- [`AvroFieldsTraversal`](https://eo.constructive.dev/integrations/avro.html) — multi-field flavour of `AvroTraversal`. Every optic ships a discipline-checked law set in `cats-eo-laws`, so @@ -104,8 +107,8 @@ for cats typeclasses. - Getting started: - Macro-derived optics (`generics`): -- circe integration: -- Avro integration: +- circe integration: +- Avro integration: - Cookbook (recipes): - Composition gap analysis (research): [`docs/research/2026-04-23-composition-gap-analysis.md`](./docs/research/2026-04-23-composition-gap-analysis.md) diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/CompositionBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/CompositionBench.scala index 69103895..9878d0a3 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/CompositionBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/CompositionBench.scala @@ -31,7 +31,7 @@ class CompositionBench extends JmhDefaults: import NestedOptics.{d3, d6, eoFlag, eoN1, eoN2, eoN3, eoN4, eoN5, eoN6, leaf} - // Leaf Lens on Nested0.value (NestedOptics ships a Getter / Setter for it, + // Leaf Lens on Nested0.value (NestedOptics ships a Getter / Modify for it, // but composition needs a full Lens). private val leafValue = EoLens[Nested0, Int](_.value, (n, v) => n.copy(value = v)) diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/SetterBench.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/SetterBench.scala index 2efd4b6b..e10c4a95 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/SetterBench.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/SetterBench.scala @@ -6,12 +6,12 @@ import java.util.concurrent.TimeUnit import dev.constructive.eo.bench.fixture.* -/** `Setter.modify` at the leaf plus deep composition, paired EO vs Monocle. +/** `Modify.modify` (eo's write-only family, named `Setter` in Monocle) at the leaf plus deep composition, paired EO vs Monocle. * - * EO's `Setter` carrier `SetterF` has both a `ForgetfulFunctor[SetterF]` (powers `.modify`) and an - * `AssociativeFunctor[SetterF]` (`assocSetterF`), so two Setters compose through the ordinary + * EO's `Modify` carrier `ModifyF` has both a `ForgetfulFunctor[ModifyF]` (powers `.modify`) and an + * `AssociativeFunctor[ModifyF]` (`assocModifyF`), so two Modify optics compose through the ordinary * `andThen` — `s1.andThen(s2).modify(f) == s1.modify(s2.modify(f))`. The depth-3 / depth-6 rows - * build a *composed* `Setter` on both sides (EO's `s3.andThen(s2)…` vs Monocle's + * build a *composed* write-only optic on both sides (EO's `s3.andThen(s2)…` vs Monocle's * `mS3.andThen(mS2)…`) and dispatch through it once, rather than hand-nesting `modify` on the EO * side. */ @@ -47,7 +47,7 @@ class SetterBench extends JmhDefaults: // ---- Nested depth sweep (composition, which Order can't express) -- - // Both sides build a composed Setter and dispatch through it once. + // Both sides build a composed write-only optic and dispatch through it once. private val eoSet3 = eoS3.andThen(eoS2).andThen(eoS1).andThen(eoSetValue) private val eoSet6 = diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/Domain.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/Domain.scala index 6f67ea2c..96898c18 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/Domain.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/Domain.scala @@ -9,7 +9,7 @@ package fixture * schema — the precondition for apples-to-apples cross-backend numbers. * * Foci the schema unlocks: - * - `order.id: Long` — depth-1 scalar (Lens / Getter / Setter; jsoniter `$.id`). + * - `order.id: Long` — depth-1 scalar (Lens / Getter / Modify; jsoniter `$.id`). * - `customer.address.street: String` — depth-3 product path (deep Lens / JsonPrism). * - `customer.loyaltyId: Option[String]` — Optional / AffineFold focus. * - `lines[*].name: String` — array Traversal (`$.lines[*].name`). diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/DomainOptics.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/DomainOptics.scala index 6992378c..219abab1 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/DomainOptics.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/DomainOptics.scala @@ -7,7 +7,7 @@ import dev.constructive.eo.optics.{ AffineFold, Getter => EoGetter, Optional => EoOptional, - Setter => EoSetter, + Modify => EoModify, } import monocle.{Getter => MGetter, Optional => MOptional, Setter => MSetter} @@ -17,7 +17,7 @@ import monocle.{Getter => MGetter, Optional => MOptional, Setter => MSetter} * `OptionalBench`, `AffineFoldBench`). * * These cover the two canonical foci the integration benches couldn't reach in their own carrier: - * - `order.id: Long` — the advertised Getter / Setter scalar (`$.id`). + * - `order.id: Long` — the advertised Getter / Modify scalar (`$.id`). * - `customer.loyaltyId: Option[String]` — the advertised Optional / AffineFold focus. Avro * omits it (kindlings encodes `Option` as a union, not a transparent field — plan 009 caveat), * but in memory it is exactly an `Optional`/`AffineFold`, so it lives here, not on a synthetic @@ -41,9 +41,9 @@ object DomainOptics: val eoGetId = EoGetter[Order, Long](_.id) val mGetId: MGetter[Order, Long] = MGetter[Order, Long](_.id) - // ---- Setter on order.id ------------------------------------------- + // ---- Modify on order.id ------------------------------------------- - val eoSetId = EoSetter[Order, Order, Long, Long](f => o => o.copy(id = f(o.id))) + val eoSetId = EoModify[Order, Order, Long, Long](f => o => o.copy(id = f(o.id))) val mSetId: MSetter[Order, Long] = MSetter[Order, Long](f => o => o.copy(id = f(o.id))) // ---- Optional / AffineFold on customer.loyaltyId ------------------ diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/Nested.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/Nested.scala index 6e908ff9..96ac4277 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/Nested.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/Nested.scala @@ -9,7 +9,7 @@ package fixture * stress case. * * Each leaf (`Nested0`) carries three kinds of focus: - * - `value: Int` — drives Lens / Getter / Setter. + * - `value: Int` — drives Lens / Getter / Modify. * - `flag: Option[String]` — drives Optional (the conditional branch is exactly the `None` side * of the `Option`). * - `items: List[Int]` — drives Fold over a `Foldable` carrier. diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/NestedOptics.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/NestedOptics.scala index 444f5ee3..c7596c13 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/NestedOptics.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/fixture/NestedOptics.scala @@ -8,7 +8,7 @@ import dev.constructive.eo.optics.{ Lens => EoLens, Optional => EoOptional, Review => EoReview, - Setter => EoSetter, + Modify => EoModify, } import monocle.{Getter => MGetter, Lens => MLens, Optional => MOptional, Setter => MSetter} @@ -111,17 +111,17 @@ object NestedOptics: val mGet6 = mG6.andThen(mG5).andThen(mG4).andThen(mG3).andThen(mG2).andThen(mG1).andThen(mGetValue) - // ---- Per-level EO Setters (SetterF carrier) --------------------- + // ---- Per-level EO Modify optics (ModifyF carrier) --------------- val eoSetValue = - EoSetter[Nested0, Nested0, Int, Int](f => n0 => n0.copy(value = f(n0.value))) - - val eoS1 = EoSetter[Nested1, Nested1, Nested0, Nested0](f => x => x.copy(n = f(x.n))) - val eoS2 = EoSetter[Nested2, Nested2, Nested1, Nested1](f => x => x.copy(n = f(x.n))) - val eoS3 = EoSetter[Nested3, Nested3, Nested2, Nested2](f => x => x.copy(n = f(x.n))) - val eoS4 = EoSetter[Nested4, Nested4, Nested3, Nested3](f => x => x.copy(n = f(x.n))) - val eoS5 = EoSetter[Nested5, Nested5, Nested4, Nested4](f => x => x.copy(n = f(x.n))) - val eoS6 = EoSetter[Nested6, Nested6, Nested5, Nested5](f => x => x.copy(n = f(x.n))) + EoModify[Nested0, Nested0, Int, Int](f => n0 => n0.copy(value = f(n0.value))) + + val eoS1 = EoModify[Nested1, Nested1, Nested0, Nested0](f => x => x.copy(n = f(x.n))) + val eoS2 = EoModify[Nested2, Nested2, Nested1, Nested1](f => x => x.copy(n = f(x.n))) + val eoS3 = EoModify[Nested3, Nested3, Nested2, Nested2](f => x => x.copy(n = f(x.n))) + val eoS4 = EoModify[Nested4, Nested4, Nested3, Nested3](f => x => x.copy(n = f(x.n))) + val eoS5 = EoModify[Nested5, Nested5, Nested4, Nested4](f => x => x.copy(n = f(x.n))) + val eoS6 = EoModify[Nested6, Nested6, Nested5, Nested5](f => x => x.copy(n = f(x.n))) // ---- Per-level Monocle Setters + composed set-3/set-6 ----------- diff --git a/benchmarks/src/main/scala/dev/constructive/eo/bench/package.scala b/benchmarks/src/main/scala/dev/constructive/eo/bench/package.scala index 4ca6574a..b7ba8fe6 100644 --- a/benchmarks/src/main/scala/dev/constructive/eo/bench/package.scala +++ b/benchmarks/src/main/scala/dev/constructive/eo/bench/package.scala @@ -27,6 +27,6 @@ package object bench: Getter => EoGetter, Lens => EoLens, Prism => EoPrism, - Setter => EoSetter, + Modify => EoModify, Traversal => EoTraversal, } diff --git a/core/src/main/scala/dev/constructive/eo/compose/Composer.scala b/core/src/main/scala/dev/constructive/eo/compose/Composer.scala index 5a0200de..507bf291 100644 --- a/core/src/main/scala/dev/constructive/eo/compose/Composer.scala +++ b/core/src/main/scala/dev/constructive/eo/compose/Composer.scala @@ -5,7 +5,7 @@ import optics.Optic /** Bridge between carriers — reshape an `F`-carrier optic into a `G`-carrier optic. Used by * `Optic.morph`; the mechanism by which optic families cross boundaries (Lens → Optional, Lens → - * Setter, Iso → Lens, …). + * Modify, Iso → Lens, …). * * @tparam F * source carrier @@ -60,23 +60,33 @@ object Composer extends LowPriorityComposerInstances: // conforms to `T`) keeps the match total and compiler-verified — no `???` needed. case Left(x) => x - /** Express a read-only Direct optic (a Getter) as a Fold — lift the single focus into `F` via - * `pure`. This is the `Composer[Direct, Forget[F]]` bridge - * [[dev.constructive.eo.laws.GetterLaws]] anticipated; it became sound once `Fold` was made - * honestly one-way (`B = Unit`), because the `from` is now genuinely unreachable: `Forget[F]` - * admits no `ReverseAccessor`, so the resulting fold's build side is never invoked (mirrors - * [[direct2either]]'s unreachable `Left`). Powers `Getter.andThen(Fold)` and `Optic.cross` - * against a `Fold`. Requires `Applicative[F]` for `pure`. + /** Express a Direct optic (a Getter, Review, or Iso) as a `Forget[F]`-carrier optic — lift the + * single focus into `F` via `pure` on the read side, and pick the single `B` back out of the + * `F[B]` on the build side. This is the `Composer[Direct, Forget[F]]` bridge + * [[dev.constructive.eo.laws.GetterLaws]] anticipated. Powers `Getter.andThen(Fold)`, + * `Optic.cross` against a `Fold`, and — since [[optics.Unfold]] gave `Forget[F]` a genuine + * build-only citizen — the build side of `review.andThen(unfold)` chains. + * + * The `from` was a documented-unreachable `???` while `Forget[F]` had no build-capable + * inhabitant; `Unfold` made it reachable (via `assocForgetMonad.composeFrom` on a `Monad[F]` + * chain). The singleton pick is sound on every reachable path: the only `F[B]` ever fed to a + * lifted Direct optic's `from` is `ForgetPull.monadicPull`'s `pure(b)`. A hand-routed call with + * cardinality ≠ 1 throws, mirroring the other `pickSingletonOrThrow` bridges. Requires + * `Applicative[F]` for `pure` and `Foldable[F]` for the pick. * * @group Instances */ - given direct2forget[F[_]](using F: cats.Applicative[F]): Composer[Direct, data.Forget[F]] with + given direct2forget[F[_]](using + F: cats.Applicative[F], + FF: cats.Foldable[F], + ): Composer[Direct, data.Forget[F]] with def to[S, T, A, B](o: Optic[S, T, A, B, Direct]): Optic[S, T, A, B, data.Forget[F]] = new Optic[S, T, A, B, data.Forget[F]]: type X = Nothing def to(s: S): data.Forget[F][X, A] = data.Forget(F.pure(o.to(s).value)) - def from(u: data.Forget[F][X, B]): T = ??? + def from(fb: data.Forget[F][X, B]): T = + o.from(Direct(data.MultiFocusK.pickSingletonOrThrow[F, B](fb.value, "Direct"))) /** Low-priority `Composer` instances — * [[LowPriorityComposerInstances.chainViaTuple2 chainViaTuple2]], a transitive derivation pinned @@ -88,7 +98,7 @@ trait LowPriorityComposerInstances: /** Transitive derivation via `Tuple2` as the intermediate carrier: given `F → Tuple2` and * `Tuple2 → G`, derive `F → G`. Fires cleanly for Direct-origin chains to any target with a - * `Composer[Tuple2, _]` direct (Affine / SetterF / MultiFocus[F] / MultiFocus[PSVec]). + * `Composer[Tuple2, _]` direct (Affine / ModifyF / MultiFocus[F] / MultiFocus[PSVec]). * * @group Instances */ diff --git a/core/src/main/scala/dev/constructive/eo/data/Forget.scala b/core/src/main/scala/dev/constructive/eo/data/Forget.scala index 1364335f..f1536a1e 100644 --- a/core/src/main/scala/dev/constructive/eo/data/Forget.scala +++ b/core/src/main/scala/dev/constructive/eo/data/Forget.scala @@ -14,7 +14,8 @@ import optics.Optic * `X`. Equivalent to the classic Haskell `newtype Forget r a b = Forget (a -> r)` construction but * applied to a type constructor `F`: `Forget[F][X, A] = F[A]`, ignoring `X` completely. * - * Used by [[dev.constructive.eo.optics.Fold]] and the multi-focus family + * Used by [[dev.constructive.eo.optics.Fold]] (read-only), its build-only dual + * [[dev.constructive.eo.optics.Unfold]], and the multi-focus family * ([[dev.constructive.eo.data.MultiFocus]]) as a uniform "F-shape carrier" whose optic-level * capabilities scale with the typeclasses `F` itself admits. */ diff --git a/core/src/main/scala/dev/constructive/eo/data/ModifyF.scala b/core/src/main/scala/dev/constructive/eo/data/ModifyF.scala new file mode 100644 index 00000000..d4b787d3 --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/data/ModifyF.scala @@ -0,0 +1,149 @@ +package dev.constructive.eo +package data + +import cats.Distributive +import cats.instances.function.* +import cats.syntax.functor.* + +import forgetful.* +import compose.* + +/** Carrier for the `Modify` family — pairs a source `Fst[A]` with a continuation `Snd[A] => B`. + * + * Same-carrier composition (`modify.andThen(modify)`) ships via [[ModifyF.assocModifyF]] — + * `AssociativeFunctor[ModifyF, Xo, Xi]` with `type Z = (Fst[Xo], Snd[Xi])`. The deferred-modify + * semantic fits the protocol once you observe that `composeTo` only needs to seed `(xo, identity)` + * (no inner-`to` call required, since ModifyF's continuation is structurally identity at every + * canonical construction site — `coerceToModify` and `Modify.apply`); `composeFrom` then extracts + * the user's `c2d` from the mapped continuation and routes it through `inner.from` then + * `outer.from`. The asInstanceOf casts inside the instance are sound under the universal + * convention that every ModifyF optic stores `X = (S_outer, A_focus)` (enforced at every + * construction site). + * + * @tparam A + * existential leftover tuple + * @tparam B + * focus written back + */ +class ModifyF[A, B](val modifier: (Fst[A], Snd[A] => B)) extends AnyVal + +/** Typeclass instances for [[ModifyF]]. */ +object ModifyF: + + /** `ForgetfulFunctor[ModifyF]` — maps the continuation through `f`, leaving the source unchanged. + * Unlocks `.modify` / `.replace` on Modify-carrier optics. + * + * @group Instances + */ + given map: ForgetfulFunctor[ModifyF] with + + def map[X, B, C](fa: ModifyF[X, B], f: B => C): ModifyF[X, C] = + val inner = fa.modifier._2 + ModifyF(fa.modifier._1, a => f(inner(a))) + + /** `ForgetfulTraverse[ModifyF, Distributive]` — lifts an effectful `B => G[C]` through the + * continuation under `Distributive[G]` (the right shape for read-once / write-once Modify). + * + * @group Instances + */ + given traverse: ForgetfulTraverse[ModifyF, Distributive] with + + def traverse[X, B, C, G[_]](fa: ModifyF[X, B], g: B => G[C])(using + D: Distributive[G] + ): G[ModifyF[X, C]] = + D.tupleLeft(D.distribute(fa.modifier._2)(g), fa.modifier._1).map(ModifyF(_)) + + /** Shared skeleton for `Composer[F, ModifyF]` instances. Every carrier materialises a coerced + * Optic with the same `type X = (S, A)` shape and an identity `to` that seeds the ModifyF; only + * `applyWrite` differs per carrier. + */ + private def coerceToModify[F[_, _], S, T, A, B]( + applyWrite: (S, A => B) => T + ): optics.Optic[S, T, A, B, ModifyF] = + new optics.Optic[S, T, A, B, ModifyF]: + type X = (S, A) + def to(s: S): ModifyF[X, A] = ModifyF((s, identity[A])) + + def from(sfxb: ModifyF[X, B]): T = + val (s, f) = sfxb.modifier + applyWrite(s, f) + + /** Lens → ModifyF. Every Lens is-a Modify; powers cross-carrier `lens.andThen(modify)` via + * `Morph[Tuple2, ModifyF]`. + * + * @group Instances + */ + given tuple2modify: Composer[Tuple2, ModifyF] with + + def to[S, T, A, B](o: optics.Optic[S, T, A, B, Tuple2]): optics.Optic[S, T, A, B, ModifyF] = + coerceToModify: (s, f) => + val (xo, a) = o.to(s) + o.from((xo, f(a))) + + /** Prism → ModifyF. Hit writes `f(a)` through the Prism's build path; miss passes the leftover + * back via `o.from(Left(xo))` — observably the same as the Prism's own `.modify(f)`. + * + * @group Instances + */ + given either2modify: Composer[Either, ModifyF] with + + def to[S, T, A, B](o: optics.Optic[S, T, A, B, Either]): optics.Optic[S, T, A, B, ModifyF] = + coerceToModify: (s, f) => + o.to(s) match + case Right(a) => o.from(Right(f(a))) + case Left(xo) => o.from(Left(xo)) + + /** Optional → ModifyF. Mirror of [[either2modify]] split across `Affine.Hit` / `Affine.Miss`; + * miss uses `widenB` instead of allocating a fresh `Miss`. + * + * @group Instances + */ + given affine2modify: Composer[Affine, ModifyF] with + + def to[S, T, A, B](o: optics.Optic[S, T, A, B, Affine]): optics.Optic[S, T, A, B, ModifyF] = + coerceToModify: (s, f) => + o.to(s) match + case h: Affine.Hit[o.X, A] => + o.from(new Affine.Hit[o.X, B](h.snd, f(h.b))) + case m: Affine.Miss[o.X, A] => + o.from(m.widenB[B]) + + // MultiFocus[F] → ModifyF lives in `MultiFocus.scala` (`multifocus2modify`). + + /** Same-carrier composition for `Optic[…, ModifyF]` — closes top-5 plan gap #4. + * + * Encoding: `Z = (Fst[Xo], Snd[Xi])`. `composeTo` seeds the ModifyF with `(outer-source, + * identity[C])` — no `inner.to` call needed because ModifyF's continuation is structurally + * `identity[C]` at every canonical construction site (every bundled ModifyF optic uses + * [[coerceToModify]] or [[optics.Modify.apply]], both of which seed identity). `composeFrom` + * extracts the user's `c2d: C => D` from the post-`map` continuation and applies it through + * `inner.from` then `outer.from`, matching the deferred-modify semantic + * `composedModify(c2d)(s) = outer.modify(inner.modify(c2d))(s)`. + * + * The asInstanceOf casts coerce abstract `Fst[Xo] / Snd[Xo] / Fst[Xi] / Snd[Xi]` to the + * canonical `(S, A)` / `(A, C)` decomposition. Sound under the universal ModifyF convention, + * unsafe only for hand-built ModifyF optics that violate it — and there's no public API path to + * build such an optic. + * + * @group Instances + */ + given assocModifyF[Xo, Xi]: AssociativeFunctor[ModifyF, Xo, Xi] with + type Z = (Fst[Xo], Snd[Xi]) + + def composeTo[S, T, A, B, C, D]( + s: S, + outer: optics.Optic[S, T, A, B, ModifyF] { type X = Xo }, + inner: optics.Optic[A, B, C, D, ModifyF] { type X = Xi }, + ): ModifyF[Z, C] = + val xo: Fst[Xo] = outer.to(s).modifier._1 + ModifyF((xo, identity[C].asInstanceOf[Snd[Xi] => C])) + + def composeFrom[S, T, A, B, C, D]( + xd: ModifyF[Z, D], + inner: optics.Optic[A, B, C, D, ModifyF] { type X = Xi }, + outer: optics.Optic[S, T, A, B, ModifyF] { type X = Xo }, + ): T = + val xo: Fst[Xo] = xd.modifier._1.asInstanceOf[Fst[Xo]] + val c2d: Snd[Xi] => D = xd.modifier._2.asInstanceOf[Snd[Xi] => D] + val innerModify: A => B = a => inner.from(ModifyF((a.asInstanceOf[Fst[Xi]], c2d))) + outer.from(ModifyF((xo, innerModify.asInstanceOf[Snd[Xo] => B]))) diff --git a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala index aa125be4..0dd23225 100644 --- a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala +++ b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala @@ -240,7 +240,7 @@ object MultiFocusK: } arr - private def pickSingletonOrThrow[F[_]: Foldable, B](fb: F[B], carrier: String): B = + private[eo] def pickSingletonOrThrow[F[_]: Foldable, B](fb: F[B], carrier: String): B = val sz = Foldable[F].size(fb) if sz == 1 then Foldable[F].reduceLeftToOption(fb)(identity[B])((_, b) => b).get else @@ -680,15 +680,15 @@ object MultiFocusK: if len == 0 then o.from(new Affine.Miss[o.X, B](y.asInstanceOf[Fst[o.X]])) else o.from(new Affine.Hit[o.X, B](y.asInstanceOf[Snd[o.X]], vys(pos))) - /** MultiFocus[F] → SetterF. Uniform Setter widening for any `Functor[F]`. */ - given multifocus2setter[F[_]: Functor]: Composer[MultiFocus[F], SetterF] with + /** MultiFocus[F] → ModifyF. Uniform Modify widening for any `Functor[F]`. */ + given multifocus2modify[F[_]: Functor]: Composer[MultiFocus[F], ModifyF] with - def to[S, T, A, B](o: Optic[S, T, A, B, MultiFocus[F]]): Optic[S, T, A, B, SetterF] = - new Optic[S, T, A, B, SetterF]: + def to[S, T, A, B](o: Optic[S, T, A, B, MultiFocus[F]]): Optic[S, T, A, B, ModifyF] = + new Optic[S, T, A, B, ModifyF]: type X = (S, A) - def to(s: S): SetterF[X, A] = SetterF((s, identity[A])) - def from(sfxb: SetterF[X, B]): T = - val (s, f) = sfxb.setter + def to(s: S): ModifyF[X, A] = ModifyF((s, identity[A])) + def from(sfxb: ModifyF[X, B]): T = + val (s, f) = sfxb.modifier val (x, fa) = o.to(s) o.from((x, Functor[F].map(fa)(f))) diff --git a/core/src/main/scala/dev/constructive/eo/data/SetterF.scala b/core/src/main/scala/dev/constructive/eo/data/SetterF.scala deleted file mode 100644 index ac797ced..00000000 --- a/core/src/main/scala/dev/constructive/eo/data/SetterF.scala +++ /dev/null @@ -1,149 +0,0 @@ -package dev.constructive.eo -package data - -import cats.Distributive -import cats.instances.function.* -import cats.syntax.functor.* - -import forgetful.* -import compose.* - -/** Carrier for the `Setter` family — pairs a source `Fst[A]` with a continuation `Snd[A] => B`. - * - * Same-carrier composition (`setter.andThen(setter)`) ships via [[SetterF.assocSetterF]] — - * `AssociativeFunctor[SetterF, Xo, Xi]` with `type Z = (Fst[Xo], Snd[Xi])`. The deferred-modify - * semantic fits the protocol once you observe that `composeTo` only needs to seed `(xo, identity)` - * (no inner-`to` call required, since SetterF's continuation is structurally identity at every - * canonical construction site — `coerceToSetter` and `Setter.apply`); `composeFrom` then extracts - * the user's `c2d` from the mapped continuation and routes it through `inner.from` then - * `outer.from`. The asInstanceOf casts inside the instance are sound under the universal - * convention that every SetterF optic stores `X = (S_outer, A_focus)` (enforced at every - * construction site). - * - * @tparam A - * existential leftover tuple - * @tparam B - * focus written back - */ -class SetterF[A, B](val setter: (Fst[A], Snd[A] => B)) extends AnyVal - -/** Typeclass instances for [[SetterF]]. */ -object SetterF: - - /** `ForgetfulFunctor[SetterF]` — maps the continuation through `f`, leaving the source unchanged. - * Unlocks `.modify` / `.replace` on Setter-carrier optics. - * - * @group Instances - */ - given map: ForgetfulFunctor[SetterF] with - - def map[X, B, C](fa: SetterF[X, B], f: B => C): SetterF[X, C] = - val inner = fa.setter._2 - SetterF(fa.setter._1, a => f(inner(a))) - - /** `ForgetfulTraverse[SetterF, Distributive]` — lifts an effectful `B => G[C]` through the - * continuation under `Distributive[G]` (the right shape for read-once / write-once Setter). - * - * @group Instances - */ - given traverse: ForgetfulTraverse[SetterF, Distributive] with - - def traverse[X, B, C, G[_]](fa: SetterF[X, B], g: B => G[C])(using - D: Distributive[G] - ): G[SetterF[X, C]] = - D.tupleLeft(D.distribute(fa.setter._2)(g), fa.setter._1).map(SetterF(_)) - - /** Shared skeleton for `Composer[F, SetterF]` instances. Every carrier materialises a coerced - * Optic with the same `type X = (S, A)` shape and an identity `to` that seeds the SetterF; only - * `applyWrite` differs per carrier. - */ - private def coerceToSetter[F[_, _], S, T, A, B]( - applyWrite: (S, A => B) => T - ): optics.Optic[S, T, A, B, SetterF] = - new optics.Optic[S, T, A, B, SetterF]: - type X = (S, A) - def to(s: S): SetterF[X, A] = SetterF((s, identity[A])) - - def from(sfxb: SetterF[X, B]): T = - val (s, f) = sfxb.setter - applyWrite(s, f) - - /** Lens → SetterF. Every Lens is-a Setter; powers cross-carrier `lens.andThen(setter)` via - * `Morph[Tuple2, SetterF]`. - * - * @group Instances - */ - given tuple2setter: Composer[Tuple2, SetterF] with - - def to[S, T, A, B](o: optics.Optic[S, T, A, B, Tuple2]): optics.Optic[S, T, A, B, SetterF] = - coerceToSetter: (s, f) => - val (xo, a) = o.to(s) - o.from((xo, f(a))) - - /** Prism → SetterF. Hit writes `f(a)` through the Prism's build path; miss passes the leftover - * back via `o.from(Left(xo))` — observably the same as the Prism's own `.modify(f)`. - * - * @group Instances - */ - given either2setter: Composer[Either, SetterF] with - - def to[S, T, A, B](o: optics.Optic[S, T, A, B, Either]): optics.Optic[S, T, A, B, SetterF] = - coerceToSetter: (s, f) => - o.to(s) match - case Right(a) => o.from(Right(f(a))) - case Left(xo) => o.from(Left(xo)) - - /** Optional → SetterF. Mirror of [[either2setter]] split across `Affine.Hit` / `Affine.Miss`; - * miss uses `widenB` instead of allocating a fresh `Miss`. - * - * @group Instances - */ - given affine2setter: Composer[Affine, SetterF] with - - def to[S, T, A, B](o: optics.Optic[S, T, A, B, Affine]): optics.Optic[S, T, A, B, SetterF] = - coerceToSetter: (s, f) => - o.to(s) match - case h: Affine.Hit[o.X, A] => - o.from(new Affine.Hit[o.X, B](h.snd, f(h.b))) - case m: Affine.Miss[o.X, A] => - o.from(m.widenB[B]) - - // MultiFocus[F] → SetterF lives in `MultiFocus.scala` (`multifocus2setter`). - - /** Same-carrier composition for `Optic[…, SetterF]` — closes top-5 plan gap #4. - * - * Encoding: `Z = (Fst[Xo], Snd[Xi])`. `composeTo` seeds the SetterF with `(outer-source, - * identity[C])` — no `inner.to` call needed because SetterF's continuation is structurally - * `identity[C]` at every canonical construction site (every bundled SetterF optic uses - * [[coerceToSetter]] or [[optics.Setter.apply]], both of which seed identity). `composeFrom` - * extracts the user's `c2d: C => D` from the post-`map` continuation and applies it through - * `inner.from` then `outer.from`, matching the deferred-modify semantic - * `composedModify(c2d)(s) = outer.modify(inner.modify(c2d))(s)`. - * - * The asInstanceOf casts coerce abstract `Fst[Xo] / Snd[Xo] / Fst[Xi] / Snd[Xi]` to the - * canonical `(S, A)` / `(A, C)` decomposition. Sound under the universal SetterF convention, - * unsafe only for hand-built SetterF optics that violate it — and there's no public API path to - * build such an optic. - * - * @group Instances - */ - given assocSetterF[Xo, Xi]: AssociativeFunctor[SetterF, Xo, Xi] with - type Z = (Fst[Xo], Snd[Xi]) - - def composeTo[S, T, A, B, C, D]( - s: S, - outer: optics.Optic[S, T, A, B, SetterF] { type X = Xo }, - inner: optics.Optic[A, B, C, D, SetterF] { type X = Xi }, - ): SetterF[Z, C] = - val xo: Fst[Xo] = outer.to(s).setter._1 - SetterF((xo, identity[C].asInstanceOf[Snd[Xi] => C])) - - def composeFrom[S, T, A, B, C, D]( - xd: SetterF[Z, D], - inner: optics.Optic[A, B, C, D, SetterF] { type X = Xi }, - outer: optics.Optic[S, T, A, B, SetterF] { type X = Xo }, - ): T = - val xo: Fst[Xo] = xd.setter._1.asInstanceOf[Fst[Xo]] - val c2d: Snd[Xi] => D = xd.setter._2.asInstanceOf[Snd[Xi] => D] - val innerModify: A => B = a => inner.from(SetterF((a.asInstanceOf[Fst[Xi]], c2d))) - outer.from(SetterF((xo, innerModify.asInstanceOf[Snd[Xo] => B]))) diff --git a/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFold.scala b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFold.scala index 2d1075c4..a2732ddc 100644 --- a/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFold.scala +++ b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulFold.scala @@ -28,5 +28,5 @@ object ForgetfulFold: ea.fold(_ => Monoid[M].empty, f) // `ForgetfulFold[Affine]` is NOT here — it is carrier-owned (`Affine.fold`), matching - // Direct / SetterF / MultiFocus / Forget. Only the stdlib carriers (Tuple2, Either) live in this + // Direct / ModifyF / MultiFocus / Forget. Only the stdlib carriers (Tuple2, Either) live in this // companion, since their own companions can't be extended. diff --git a/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulTraverse.scala b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulTraverse.scala index b8e58309..5627f241 100644 --- a/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulTraverse.scala +++ b/core/src/main/scala/dev/constructive/eo/forgetful/ForgetfulTraverse.scala @@ -8,7 +8,7 @@ import cats.{Applicative, Functor} /** Traverse the focus of `F[_, _]` under an effectful `A => G[B]`. Parameterised by the applicative * constraint `C[_[_]]` — `Applicative` for carriers with miss branches, `Functor` for Tuple2, - * `Distributive` for SetterF. + * `Distributive` for ModifyF. * * @tparam F * the carrier @@ -47,6 +47,6 @@ object ForgetfulTraverse: ) // `ForgetfulTraverse[Affine]` is NOT here — it is carrier-owned (`Affine.traverse`), matching - // Direct / SetterF / MultiFocus / Forget. Only the stdlib carriers (Tuple2, Either) live in this + // Direct / ModifyF / MultiFocus / Forget. Only the stdlib carriers (Tuple2, Either) live in this // companion, since their own companions can't be extended. A duplicate here would shadow-tie the // carrier-side instance and force every call site to disambiguate via `import data.Affine.given`. diff --git a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala index 80a41250..0f9c6a54 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala @@ -9,7 +9,8 @@ import compose.* /** Constructors for `Fold` — read-only multi-focus optic, backed by `Forget[F]` (`Forget[F][X, A] = * F[A]`). `T = Unit` rules out the write path; `.foldMap` is the consumption surface. - * `Fold.select(p)` narrows to a one-element `Option` stream. + * `Fold.select(p)` narrows to a one-element `Option` stream. The build-only dual on the same + * carrier — assemble a `T` *from* an `F`-layer — is [[Unfold]]. * * Both constructors return the concrete [[ForgetFold]] subclass so a hand-written Fold picks up * its eager, carrier-free `foldMap` member (see [[ForgetFold.foldMap]]). @@ -38,8 +39,7 @@ object Fold: * `Foldable[F]` directly. This lets the terminal [[foldMap]] fold the focus eagerly through the * captured `Foldable[F]`, skipping both the per-call `ForgetfulFold[Forget[F]]` summon and the * intermediate `S => M` closure the generic `Optic.foldMap` extension builds — the same - * specialisation `GetReplaceLens` / `SetterOptic` / `MultiFocusSingleton` apply to their hot - * paths. + * specialisation `GetReplaceLens` / `Modify` / `MultiFocusSingleton` apply to their hot paths. * * Returned by every `Fold.*` constructor so hand-written folds pick up the fast path * automatically. A *composed* Fold (the result of `.andThen`) surfaces as the erased diff --git a/core/src/main/scala/dev/constructive/eo/optics/Lens.scala b/core/src/main/scala/dev/constructive/eo/optics/Lens.scala index 20e55fbd..4445e229 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Lens.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Lens.scala @@ -91,8 +91,8 @@ class GetReplaceLens[S, T, A, B]( * methods per level). A plain `def` shares one `andThen$$anonfun$*` across a depth-N chain, so * C2 reads the `.get` / `.enplace` cascade as recursion and caps inlining at * `MaxRecursiveInlineLevel`, leaving the deep tail as virtual `Function1.apply` — the same - * same-bytecode trap fixed on [[DirectGetter.andThen]]. This is the recursive composer, so it's - * the one that matters for deep chains; the terminal mixed-carrier overloads below fire once and + * same-bytecode trap fixed on [[Getter.andThen]]. This is the recursive composer, so it's the + * one that matters for deep chains; the terminal mixed-carrier overloads below fire once and * stay plain `def` to avoid duplicating their larger bodies. */ inline def andThen[C, D](inner: GetReplaceLens[A, B, C, D]): GetReplaceLens[S, T, C, D] = diff --git a/core/src/main/scala/dev/constructive/eo/optics/Modify.scala b/core/src/main/scala/dev/constructive/eo/optics/Modify.scala new file mode 100644 index 00000000..68278362 --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/optics/Modify.scala @@ -0,0 +1,63 @@ +package dev.constructive.eo +package optics + +import data.ModifyF + +/** Constructor for `Modify` — write-only single-focus optic, backed by `ModifyF`. The caller + * applies a function at the focus but cannot read it back; useful when observation would leak + * information or when the focus is genuinely unreadable (e.g. inside a closure). + * + * [[Modify.apply]] returns a concrete [[Modify]], so a Modify composes with another Modify through + * the ordinary `andThen` (the fused [[Modify.andThen]]) — `s1.andThen(s2).modify(f) == + * s1.modify(s2.modify(f))` — exactly as `Iso` / `Lens` / `Getter` compose via their own fused + * subclasses, bypassing the generic `AssociativeFunctor[ModifyF]` round-trip. + */ +object Modify: + + /** Construct from `modify: (A => B) => S => T`. + * + * @group Constructors + * + * @example + * {{{ + * case class Config(values: Map[String, Int]) + * val bumpAll = Modify[Config, Config, Int, Int] { f => cfg => + * cfg.copy(values = cfg.values.view.mapValues(f).toMap) + * } + * bumpAll.modify(_ + 1)(cfg) + * }}} + */ + def apply[S, T, A, B](modify: (A => B) => S => T): Modify[S, T, A, B] = + new Modify(modify) + +/** Concrete Optic subclass for a write-only modifier. Stores the writer `modifyFn` directly (so the + * hot path skips the `ModifyF` carrier round-trip the generic extension performs) and carries a + * fused `andThen` for modify∘modify composition — the same shape as [[GetReplaceLens]] / + * [[Getter]]. Returned by [[Modify.apply]] so hand-written modifiers pick up the fused path + * automatically. + */ +final class Modify[S, T, A, B](val modifyFn: (A => B) => S => T) extends Optic[S, T, A, B, ModifyF]: + type X = (S, A) + + def to(s: S): ModifyF[X, A] = ModifyF(s, identity[A]) + + def from(s: ModifyF[X, B]): T = modifyFn(s.modifier._2)(s.modifier._1) + + /** Fused `.modify` — applies the stored writer directly, bypassing the `ModifyF` carrier. */ + inline def modify(f: A => B): S => T = modifyFn(f) + + /** Fused `.replace` — constant-writer special case of [[modify]]. */ + inline def replace(b: B): S => T = modifyFn(_ => b) + + /** Fused `Modify.andThen(Modify)` — composes the writers directly: + * `modify(g) == outer.modify(inner.modify(g))`, skipping the generic + * `AssociativeFunctor[ModifyF]` (`assocModifyF`) round-trip and its per-hop `ModifyF` allocation + * + `asInstanceOf` threading. Scala's overload resolution picks this when both sides are + * statically `Modify`; mixed-carrier composition falls back to the inherited generic + * `Optic.andThen`. + * + * `inline` so a deep `modify.andThen(modify)…` chain splices distinct writer lambdas per level, + * staying under C2's recursive-inline cap (see [[Getter.andThen]]). + */ + inline def andThen[C, D](inner: Modify[A, B, C, D]): Modify[S, T, C, D] = + new Modify(cd => modifyFn(inner.modifyFn(cd))) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala index f0a9fc09..c705bf06 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala @@ -30,7 +30,7 @@ import compose.* * two-argument carrier; capabilities scale with the typeclasses `F` admits. * * @see - * [[Lens]], [[Prism]], [[Iso]], [[Optional]], [[Setter]], [[Traversal]], [[Getter]], [[Fold]] + * [[Lens]], [[Prism]], [[Iso]], [[Optional]], [[Modify]], [[Traversal]], [[Getter]], [[Fold]] */ trait Optic[S, T, A, B, F[_, _]]: self => @@ -116,7 +116,7 @@ trait Optic[S, T, A, B, F[_, _]]: /** Companion for [[Optic]]. Hosts the profunctor instances and the capability-gated extension * catalogue — `.get`, `.modify`, `.replace`, `.foldMap`, `.modifyA`, `.all`, `.reverseGet`, * `.getOption`, `.put`, `.transform`, `.place`, `.transfer`, `.andThen` (carrier-morphing plus the - * read-only / AffineFold / Setter / Review collapses), `.readOnly`, `.cross`, `.morph`, + * read-only / AffineFold / Modify / Review / Unfold collapses), `.readOnly`, `.cross`, `.morph`, * `.headOption`, `.length`, `.exists`. Adding a new carrier means supplying the typeclass * instances of the operations it should support. */ @@ -215,6 +215,15 @@ object Optic: inline def andThen[D](o: Review[B, D]): Review[T, D] = Review(d => reverseGet(o.reverseGet(d))) + /** ANY reversible outer ∘ build-only-many inner — the inner [[Unfold]] assembles the focus `B` + * from a layer `F[D]`, and this optic's build half re-homes it to `T`, so the composite is an + * `Unfold[T, D, F]` (`reverseGet ∘ embed`). The many-rung mirror of the `andThen(Review)` + * overload above; fires for Iso / Prism outers (`Review` outers resolve to the fused + * [[Review.andThen]] member first). + */ + inline def andThen[G[_], D](o: Unfold[B, D, G]): Unfold[T, D, G] = + o.into(b => reverseGet(b)) + inline def writeOnly: Review[T, B] = Review(reverseGet) @@ -254,8 +263,8 @@ object Optic: inline def replace(b: B): S => T = s => self.from(FF.map(self.to(s), _ => b)) - inline def andThen[C, D](o: Setter[A, B, C, D]): Setter[S, T, C, D] = - Setter(f => modify(o.modify(f))) + inline def andThen[C, D](o: Modify[A, B, C, D]): Modify[S, T, C, D] = + Modify(f => modify(o.modify(f))) /** Overwrite a `T`-shaped value at the focus — available when the carrier can witness `T => F[X, * B]` (e.g. `Direct`, where `F[X, B] = B`). [[transfer]] lifts a `C => B` into this same shape 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 06e838ad..428a8daa 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 cats.Eval -import data.{MultiFocus, PSVec, SetterF} +import data.{MultiFocus, PSVec, ModifyF} /** A self-similar structure: a value of `S` whose immediate sub-terms are themselves `S`. The * single member [[plate]] is a `Traversal[S, S]` focusing those immediate children — the cats-eo @@ -167,13 +167,13 @@ object Plated: * everywhere[Expr].andThen(varPrism).andThen(nameLens).modify(_.toUpperCase)(tree) * }}} * - * It is a write-side optic (a [[Setter]] whose `modify` is the recursive [[transform]]); it - * composes as the *outer* of `.andThen` with any inner optic that bridges into `SetterF` (Lens / + * It is a write-side optic (a [[Modify]] whose `modify` is the recursive [[transform]]); it + * composes as the *outer* of `.andThen` with any inner optic that bridges into `ModifyF` (Lens / * Prism / Optional / …), and the composite is `transform(inner.modify(_))` by the optic * composition law. For the read side — every sub-term as a list — use [[universe]]. */ - def everywhere[S](using P: Plated[S]): Optic[S, S, S, S, SetterF] = - Setter[S, S, S, S](g => s => transform(g)(s)) + def everywhere[S](using P: Plated[S]): Optic[S, S, S, S, ModifyF] = + Modify[S, S, S, S](g => s => transform(g)(s)) /** Apply the rule everywhere, bottom-up, and keep re-firing on each rewritten sub-term until the * rule returns `None` at every node — Haskell `lens`'s `rewrite`. The caller owns termination (a diff --git a/core/src/main/scala/dev/constructive/eo/optics/Review.scala b/core/src/main/scala/dev/constructive/eo/optics/Review.scala index cf6297f7..0bc0f913 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Review.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Review.scala @@ -22,6 +22,9 @@ import data.Direct * `Review(iso.reverseGet)` / `Review(prism.mend)` — rather than via a bespoke factory: an * `Iso`/`Prism` already *is* a build direction, so cross-optic `from*` constructors would be * redundant (eo has no `Prism.fromIso` etc. for the same reason). + * + * Review builds from ONE focus; its many-rung sibling is [[Unfold]] (assemble a `T` from an + * `F`-layer of parts), reachable by composition through the fused `andThen(Unfold)` below. */ final class Review[T, B](val reverseGet: B => T) extends Optic[Unit, T, Unit, B, Direct]: type X = Nothing @@ -43,6 +46,16 @@ final class Review[T, B](val reverseGet: B => T) extends Optic[Unit, T, Unit, B, ): Review[T, D] = Review(d => reverseGet(inner.reverseGet(d))) + /** Fused `Review.andThen(Unfold)` — post-process the assembled whole. `inner` assembles `B` from + * a layer `F[D]`; `this` builds `T` from that `B`, so the composite is an [[Unfold]] assembling + * `T` from `F[D]` (`reverseGet ∘ inner.embed`). No constraint on `F` — the seam threads a single + * `B`, never an `F`-layer, so pattern-functor algebras compose freely. A member (not an + * extension) so it out-prioritises the `Morph`-summoning generic `andThen`, which would route + * the same call through `Composer[Direct, Forget[F]]`'s singleton-pick. + */ + inline def andThen[F[_], D](inner: Unfold[B, D, F]): Unfold[T, D, F] = + inner.into(reverseGet) + /** Constructors for [[Review]]. */ object Review: diff --git a/core/src/main/scala/dev/constructive/eo/optics/Setter.scala b/core/src/main/scala/dev/constructive/eo/optics/Setter.scala deleted file mode 100644 index f0f76c8c..00000000 --- a/core/src/main/scala/dev/constructive/eo/optics/Setter.scala +++ /dev/null @@ -1,63 +0,0 @@ -package dev.constructive.eo -package optics - -import data.SetterF - -/** Constructor for `Setter` — write-only single-focus optic, backed by `SetterF`. The caller - * applies a function at the focus but cannot read it back; useful when observation would leak - * information or when the focus is genuinely unreadable (e.g. inside a closure). - * - * [[Setter.apply]] returns a concrete [[SetterOptic]], so a Setter composes with another Setter - * through the ordinary `andThen` (the fused [[SetterOptic.andThen]]) — `s1.andThen(s2).modify(f) == - * s1.modify(s2.modify(f))` — exactly as `Iso` / `Lens` / `Getter` compose via their own fused - * subclasses, bypassing the generic `AssociativeFunctor[SetterF]` round-trip. - */ -object Setter: - - /** Construct from `modify: (A => B) => S => T`. - * - * @group Constructors - * - * @example - * {{{ - * case class Config(values: Map[String, Int]) - * val bumpAll = Setter[Config, Config, Int, Int] { f => cfg => - * cfg.copy(values = cfg.values.view.mapValues(f).toMap) - * } - * bumpAll.modify(_ + 1)(cfg) - * }}} - */ - def apply[S, T, A, B](modify: (A => B) => S => T): Setter[S, T, A, B] = - new Setter(modify) - -/** Concrete Optic subclass for a write-only setter. Stores the writer `modifyFn` directly (so the - * hot path skips the `SetterF` carrier round-trip the generic extension performs) and carries a - * fused `andThen` for setter∘setter composition — the same shape as [[GetReplaceLens]] / - * [[Getter]]. Returned by [[Setter.apply]] so hand-written setters pick up the fused path - * automatically. - */ -final class Setter[S, T, A, B](val modifyFn: (A => B) => S => T) extends Optic[S, T, A, B, SetterF]: - type X = (S, A) - - def to(s: S): SetterF[X, A] = SetterF(s, identity[A]) - - def from(s: SetterF[X, B]): T = modifyFn(s.setter._2)(s.setter._1) - - /** Fused `.modify` — applies the stored writer directly, bypassing the `SetterF` carrier. */ - inline def modify(f: A => B): S => T = modifyFn(f) - - /** Fused `.replace` — constant-writer special case of [[modify]]. */ - inline def replace(b: B): S => T = modifyFn(_ => b) - - /** Fused `Setter.andThen(Setter)` — composes the writers directly: - * `modify(g) == outer.modify(inner.modify(g))`, skipping the generic - * `AssociativeFunctor[SetterF]` (`assocSetterF`) round-trip and its per-hop `SetterF` allocation - * + `asInstanceOf` threading. Scala's overload resolution picks this when both sides are - * statically `SetterOptic`; mixed-carrier composition falls back to the inherited generic - * `Optic.andThen`. - * - * `inline` so a deep `setter.andThen(setter)…` chain splices distinct writer lambdas per level, - * staying under C2's recursive-inline cap (see [[Getter.andThen]]). - */ - inline def andThen[C, D](inner: Setter[A, B, C, D]): Setter[S, T, C, D] = - new Setter(cd => modifyFn(inner.modifyFn(cd))) diff --git a/core/src/main/scala/dev/constructive/eo/optics/Unfold.scala b/core/src/main/scala/dev/constructive/eo/optics/Unfold.scala new file mode 100644 index 00000000..a4a9239f --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/optics/Unfold.scala @@ -0,0 +1,129 @@ +package dev.constructive.eo +package optics + +import cats.{Applicative, Functor} + +import accessor.ReverseAccessor +import data.Forget + +/** Build-only counterpart to [[Fold]] — the inhabitant of the build-only / many cell of the optic + * lattice, exactly as [[Review]] is [[Getter]]'s build-only mirror on the total rung. `Fold` is + * `Optic[S, Unit, A, Unit, Forget[F]]` (a real `to` reading `S => F[A]`, vestigial `from`); + * `Unfold` is the across-both-axes dual `Optic[Unit, T, Unit, B, Forget[F]]` — a vestigial `to` + * and a real `from` that *assembles* one `T` from a layer of parts `F[B]`: + * + * {{{ + * embed : F[B] => T // many → one: the algebra of a recursion scheme + * }}} + * + * This is the F-shape `embed` of a `Corecursive` instance (`Fold`'s `S => F[A]` being the + * `project` half), and the aggregation arrow "build an `Order` from its line-items". `Plated`'s + * `plate` bundles both halves inside one read-write traversal; `Unfold` exposes the embed half + * standalone, so an algebra can be carried, composed, and consumed as an optic. + * + * '''The vestigial `to`.''' Read-only optics zero out their write side for free (`from` discards + * into `Unit`, the terminal object). The build-only dual is not free: `to: Unit => F[Unit]` must + * *produce* an `F`-layer, and there is no canonical `F[Unit]` without `Applicative[F]` + * (`pure(())`). Two constructors, two answers: + * + * - [[Unfold.apply]] (`F: Applicative`) — honest vestigial `to = pure(())`. Read-side operations + * that reach `to` (`.modify`, `.foldMap`, …) degrade to the singleton layer. + * - [[Unfold.algebra]] (no constraint) — for pattern functors (`BinF`, `RoseF`, …), which admit + * `Functor`/`Traverse` but no `Applicative` (`pure` cannot pick a constructor). Its `to` is + * genuinely unreachable through the build-only surface and THROWS if forced — the mirror of + * `Composer.direct2forget`'s formerly-`???` `from`. + * + * A `final class` storing `embed` directly — NOT an abstract member — per the composed-dispatch + * findings on [[Getter]] / [[Review]]. Fused `andThen` members keep build-only chains concrete. + */ +final class Unfold[T, B, F[_]] @scala.annotation.publicInBinary private ( + val embed: F[B] => T, + private[optics] val vestigialTo: () => F[Unit], +) extends Optic[Unit, T, Unit, B, Forget[F]]: + type X = Nothing + + def to(u: Unit): Forget[F][X, Unit] = Forget(vestigialTo()) + def from(fb: Forget[F][X, B]): T = embed(fb.value) + + /** Fused `Unfold.andThen(Review)` — pre-process each part. `inner` builds the part `B` from a + * `D`, so the composite assembles `T` from a layer of `D`s: `embed ∘ map(inner.reverseGet)`. + * Needs only `Functor[F]`, so it is available to pattern-functor algebras. `inline` for the same + * per-level-lambda reason as [[Getter.andThen]] / [[Review.andThen]]. + */ + inline def andThen[D](inner: Review[B, D])(using F: Functor[F]): Unfold[T, D, F] = + new Unfold(fd => embed(F.map(fd)(inner.reverseGet)), vestigialTo) + + /** Fused `Unfold.andThen(Unfold)` — layered algebra composition on a shared `F`. `inner` + * assembles the part `B` from its own layer `F[D]`; the composite assembles `T` from that layer + * by re-lifting the single assembled `B` via `pure` — the same algebraic-lens pull as + * `assocForgetMonad` (`from_outer ∘ pure ∘ from_inner`), so `Applicative[F]` is required and + * pattern functors are excluded, exactly as they are from same-carrier `Fold.andThen`. + */ + inline def andThen[D](inner: Unfold[B, D, F])(using F: Applicative[F]): Unfold[T, D, F] = + new Unfold(fd => embed(F.pure(inner.embed(fd))), () => F.pure(())) + + /** Build-only outer ∘ ANY reversible inner — an `Unfold` only builds, so the inner's read side is + * irrelevant and only its build half is threaded: each part `D` is mended to a `B` via + * `reverseGet` (available when `G` admits a `ReverseAccessor`: `Direct` for Iso, `Either` for + * Prism), then the layer embeds. The build-direction mirror of [[Getter]]'s `andThenReadAny`; + * the fused `andThen(Review)` member above stays as the more-specific fast path. + */ + @annotation.targetName("andThenBuildAny") + inline def andThen[G[_, _], D](inner: Optic[?, B, ?, D, G])(using + ra: ReverseAccessor[G], + F: Functor[F], + ): Unfold[T, D, F] = + new Unfold(fd => embed(F.map(fd)(d => inner.reverseGet(d))), vestigialTo) + + /** `g ∘ embed` — rehome the assembled whole. Backs the fused [[Review.andThen]] (which cannot + * reach the private constructor from its own file). + */ + private[optics] def into[U](g: T => U): Unfold[U, B, F] = + new Unfold(fb => g(embed(fb)), vestigialTo) + +/** Constructors for [[Unfold]] — build-only multi-focus optic, backed by `Forget[F]` (`Forget[F][X, + * B] = F[B]`) with `S = A = Unit` ruling out the read path; `.embed` is the consumption surface. + */ +object Unfold: + + /** Construct from `embed: F[B] => T` for an `Applicative[F]` — the vestigial `to` is honestly + * `pure(())`. + * + * @group Constructors + * + * @example + * {{{ + * val sum = Unfold((xs: List[Int]) => xs.sum) + * sum.embed(List(1, 2, 3)) // 6 + * }}} + */ + def apply[T, B, F[_]](embed: F[B] => T)(using F: Applicative[F]): Unfold[T, B, F] = + new Unfold(embed, () => F.pure(())) + + /** Construct from a recursion-scheme algebra over a pattern functor — `F` need not (and usually + * cannot) be `Applicative`, so the vestigial `to` has no canonical value and THROWS if a + * read-side operation forces it. The build-only surface (`.embed`, the fused `andThen`s, the + * build side of composition) never does. + * + * @group Constructors + * + * @example + * {{{ + * enum BinF[+A]: + * case LeafF(n: Int); case BranchF(l: A, r: A) + * + * val eval = Unfold.algebra[Int, Int, BinF] { + * case BinF.LeafF(n) => n + * case BinF.BranchF(l, r) => l + r + * } + * }}} + */ + def algebra[T, B, F[_]](embed: F[B] => T): Unfold[T, B, F] = + new Unfold( + embed, + () => + throw new UnsupportedOperationException( + "Unfold.algebra: the vestigial read side of a build-only optic over a " + + "non-Applicative F has no canonical F[Unit]; only build-side operations are available." + ), + ) diff --git a/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala b/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala index 378a1df5..d2db6548 100644 --- a/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala +++ b/core/src/test/scala/dev/constructive/eo/MultiFocusSpec.scala @@ -11,7 +11,7 @@ import org.scalacheck.Prop.forAll import org.specs2.ScalaCheck import org.specs2.mutable.Specification -import data.{Direct, MultiFocus, SetterF} +import data.{Direct, MultiFocus, ModifyF} import data.MultiFocus.{collectList, collectMap} import optics.* import optics.Optic.* @@ -23,7 +23,7 @@ private case class Person(name: String, phones: List[Phone]) /** Spike-scope spec for the unified `MultiFocus[F]` carrier — pins down the load-bearing slice the * spike claims to deliver: `.modify` (Functor[F]), `.modifyA` (Traverse[F]), same-carrier * `.andThen` (Lens → MultiFocus → Lens with singleton fast-path), the Iso bridge - * (`forgetful2multifocus`), and the SetterF widening (`multifocus2setter`). Plus the .collect + * (`forgetful2multifocus`), and the ModifyF widening (`multifocus2modify`). Plus the .collect * universal in its two derivations. */ class MultiFocusSpec extends Specification with ScalaCheck: @@ -92,15 +92,15 @@ class MultiFocusSpec extends Specification with ScalaCheck: modList && modOpt && modVec && modChain && modA } - // ----- Composer bridges: Direct → MultiFocus[List] + MultiFocus[List] → SetterF ---- + // ----- Composer bridges: Direct → MultiFocus[List] + MultiFocus[List] → ModifyF ---- // // 2026-04-29 consolidation: 2 same-shape composer-bridge tests → 1 composite. // covers: Composer[Direct, MultiFocus[List]] (forgetful2multifocus) round-trips an // Iso's .modify through the bridge — 5 → 6 (forward) → 12 (×2) → 11 (back); - // Composer[MultiFocus[F], SetterF] (multifocus2setter) widens MultiFocus[List]'s - // element-wise modify to SetterF and preserves the modify byte-for-byte - "Composer bridges: Direct → MultiFocus[List] (Iso round-trip) + MultiFocus[List] → SetterF" >> { + // Composer[MultiFocus[F], ModifyF] (multifocus2modify) widens MultiFocus[List]'s + // element-wise modify to ModifyF and preserves the modify byte-for-byte + "Composer bridges: Direct → MultiFocus[List] (Iso round-trip) + MultiFocus[List] → ModifyF" >> { val iso: Optic[Int, Int, Int, Int, Direct] = Iso[Int, Int, Int, Int](_ + 1, (b: Int) => b - 1) val asMF: Optic[Int, Int, Int, Int, MultiFocus[List]] = @@ -109,9 +109,9 @@ class MultiFocusSpec extends Specification with ScalaCheck: val k: Optic[List[Int], List[Int], Int, Int, MultiFocus[List]] = MultiFocus.apply[List, Int] - val asSetter: Optic[List[Int], List[Int], Int, Int, SetterF] = - summon[Composer[MultiFocus[List], SetterF]].to(k) - val setterOk = asSetter.modify(_ * 10)(List(1, 2, 3)) == List(10, 20, 30) + val asModify: Optic[List[Int], List[Int], Int, Int, ModifyF] = + summon[Composer[MultiFocus[List], ModifyF]].to(k) + val modifyOk = asModify.modify(_ * 10)(List(1, 2, 3)) == List(10, 20, 30) - isoOk && setterOk + isoOk && modifyOk } diff --git a/docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md b/docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md index e187b02b..e4c259ad 100644 --- a/docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md +++ b/docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md @@ -120,3 +120,28 @@ spike turns on, and it's cheap to surface. Then bring it back here and decide A This is the one you wanted to drive — so I've left A/B/C and Q1–Q5 open rather than picking. Tell me which candidate to prototype first (my vote: B, fixed-error, one circe case) and whether `from` changing shape is on or off the table. + +## Scope addendum (2026-06-11, from the 3-axis taxonomy work) + +The taxonomy figure on the docsite (focus nature × source nature × capability — +`site/docs/optics.md`, generated by `site/tools/gen-taxonomy-svg.py`) places this spike's +deliverables precisely. Axes: **focus nature** = how `to` lands (total / fallible / +multiple — the `ReadCompose` lattice); **source nature** = what `from` needs (total / +contextual / **fallible** — the rebuild can reject). With those axes the read-write grid is +*completely inhabited* except the fallible-source row — i.e. every remaining hole in the +family space IS this spike. Two items are hereby pulled INTO this plan's scope: + +1. **`WriteCompose`** — the source-side join typeclass mirroring `ReadCompose`: composing + write/build sides should land at the join of their source natures (total / contextual / + fallible), the way `ReadCompose` lands read-only chains at the join of focus natures. The + fallible rung of that lattice is exactly this spike's error channel, so the typeclass and + the carrier should be designed together. +2. **The fallible-source row carriers** (one per focus nature): + - (total focus, fallible source): *fallible write* — a smart-constructor replace that can + reject (Candidate B's per-field case); + - (fallible, fallible): **BiAffine** proper — miss on read AND reject on write; + - (multiple, fallible): *fallible each* — per-element write failures with accumulation + (Candidate C; the write-side mirror of `AvroTraversal`'s `Ior` reads). + The bottom (build-only) layer's fallible cell — *fallible build*, the `Either[E, T]`-shaped + `from` between Review and Unfold — should fall out of the same carrier generalization + rather than be designed ad hoc. diff --git a/docs/brainstorms/2026-06-10-unfold-build-many-citizen.md b/docs/brainstorms/2026-06-10-unfold-build-many-citizen.md index 8882eaaa..c8981514 100644 --- a/docs/brainstorms/2026-06-10-unfold-build-many-citizen.md +++ b/docs/brainstorms/2026-06-10-unfold-build-many-citizen.md @@ -1,7 +1,7 @@ --- date: 2026-06-10 topic: unfold-build-many-citizen -spike: open +spike: resolved (2026-06-11, branch spike/unfold-build-many-citizen — see Findings at bottom) --- # `Unfold` — inhabiting the build-only / many cell @@ -117,3 +117,70 @@ fused final-class. Rewrite `Schemes.cata` to build `plateFold.cross(unfold)` and the current `DirectGetter` result (behaviour + the typed-schemes B/op machine numbers). Success criterion: `cata`/`hylo` expressed as composition, and `direct2forget`'s `from` either becomes sound or its unreachability is provable from `Unfold`'s laws. + +## Findings (spike executed 2026-06-11, branch `spike/unfold-build-many-citizen`) + +Implemented: `core/optics/Unfold.scala`, fused member on `Review`, sound `direct2forget`, +`Schemes.cata(Unfold)` overload, `UnfoldSpec` + `SchemesSpec` addition. Follow-up pass (same +branch): `UnfoldLaws` + `UnfoldTests` discipline rulesets in `cats-eo-laws` (registered in +`OpticsLawsSpec` for both an Applicative carrier and a pattern functor), and the composition +matrix extended to the 11-family grid (121 cells) — the `Unfold` row/column exactly mirrors +`Review`'s reversibility pattern (`iso`/`prism`/`review`/`unfold` compose; everything else is +void by design). Two generalizing compositions were added for that symmetry: +`Unfold.andThen(any reversible inner)` (`ReverseAccessor[G]` + `Functor[F]`) and the +`reversible outer ∘ Unfold` extension in `Optic` (the many-rung mirror of `andThen(Review)`). +Full root aggregate green. + +- **Q1 — CONFIRMED.** `Unfold[T, B, F] = Optic[Unit, T, Unit, B, Forget[F]]` with the real map + `embed: F[B] => T`, `X = Nothing`. Final class storing `embed`, per the encoding findings. + +- **Q2 — sharper than anticipated, and decisive.** The prime consumers (pattern functors: `BinF`, + `RoseF`, …) admit `Functor`/`Traverse` but **no `Applicative`** — `pure` cannot pick a + constructor. Requiring `Applicative[F]` for the vestigial `to` would have excluded the + recursion-scheme motivation entirely. Resolution: **two factories**. `Unfold.apply` + (`F: Applicative`, honest `to = pure(())`; read-side ops degrade to the singleton layer) and + `Unfold.algebra` (constraint-free; `to` throws `UnsupportedOperationException` — the honest + mirror of `direct2forget`'s formerly-unreachable `from`, and it fails *loudly*, tested). + +- **Q3 (ladder).** `embed` itself: no constraint. `unfold.andThen(review)` (pre-process each + part): `Functor[F]` — pattern functors compose freely. `review.andThen(unfold)` (post-process + the whole): **no constraint** (the seam threads a single `B`, never an `F`-layer). + `unfold.andThen(unfold)`: `Applicative[F]` (the same algebraic-lens `pure` re-lift as + `assocForgetMonad`). `Foldable` is never needed by `Unfold` itself. + +- **Q4 (carrier).** `Forget[F]` suffices: `embed` needs no structural leftover. No seam exercised + in the spike wanted `MultiFocus[F]`; revisit only if a future consumer needs the skeleton to + survive *alongside* the build (Plated keeps both halves for exactly that reason). + +- **Q5 (composition).** Fused final-class members shipped: `andThen(Review)` / + `andThen(Unfold)` / the generalized `andThenBuildAny` on `Unfold`, plus `Review.andThen(Unfold)` + and the reversible-outer extension in `Optic`. The non-fused generic path also works: a + morph-routed `Forget[F]` chain composes via `assocForgetMonad` for `Monad[F]` — and its build + side **executes** `direct2forget.from`. + +- **Q6 — core.** It closes a core hole and its fused member lives on `Review` (core). Schemes + consumes it. + +- **`direct2forget`'s `???` is now a sound branch** (motivation #1 — confirmed). `Unfold` made it + reachable (`UnfoldSpec` proves execution via `BijectionIso[Unit,·].andThen(unfold)`). The pick + is total on every reachable path because the only `F[B]` ever fed to a lifted Direct optic's + `from` is `ForgetPull.monadicPull`'s `pure(b)`; hand-routed cardinality ≠ 1 throws like the + other `pickSingletonOrThrow` bridges. Cost: the Composer gained a `Foldable[F]` constraint — + the composition matrix (100 cells) is unaffected. + +- **`cata = plateFold.cross(unfold)` — REFUTED as literally stated** (motivation #2 — corrected). + It is a type error: `cross` needs a shared carrier or `Accessor`+`ReverseAccessor` on + `MultiFocus[PSVec]`, which rightly don't exist; conceptually `cata` is a *fixpoint* of the + layer optic, not a 2-optic composition. What **is** true and shipped: the algebra becomes a + citizen the engine consumes (`Schemes.cata(sizeAlg: Unfold[A, A, PSVec])`), and algebras can be + *assembled by optic composition* before being consumed (`Review(_*2).andThen(Unfold.algebra(…))` + — tested). Honesty limit: an untyped `PSVec` layer is node-blind, so pure `PSVec`-algebras only + express constructor-independent folds; the para overload stays primary. The typed path is where + pure algebras are fully expressive: **PR #24's `Embed[F, S]` IS `Unfold[S, S, F]`** — unifying + them (anaF taking an `Unfold`, cataF's pure overload being one) is the natural follow-up once + #24 lands. + +- **Deferred.** Laws (candidates: `(rev ∘ u).embed = rev.reverseGet ∘ u.embed`; + `(u ∘ rev).embed = u.embed ∘ map(rev.reverseGet)`; singleton degradation + `modify(f)(()) = embed(pure(f(())))` for Applicative `F`). CompositionMatrixSpec extension to + the now-11-family matrix (DONE — see above). The `Embed ≅ Unfold` unification (blocked on #24). diff --git a/laws/src/main/scala/dev/constructive/eo/laws/GetterLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/GetterLaws.scala index bfd9aced..f7279c1e 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/GetterLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/GetterLaws.scala @@ -17,9 +17,10 @@ import optics.Optic.* * * Compared to Monocle's `GetterLaws`, we omit the fold-consistency law * (`getter.fold.getAll(s).headOption == Some(getter.get(s))`). The `Composer[Direct, Forget[F]]` - * bridge that this law needs now exists (added when `Fold` was made honestly one-way, `B = Unit`, - * which made that Composer's `from` reachably-unsound-free), so a Getter→Fold morph is available; - * the law is simply not yet restated here. Future work: add it via `getter`'s morph into a `Fold`. + * bridge that this law needs now exists (and since [[dev.constructive.eo.optics.Unfold]] gave + * `Forget[F]` a build-only citizen, its `from` is a sound singleton-pick rather than a + * documented-unreachable hole), so a Getter→Fold morph is available; the law is simply not yet + * restated here. Future work: add it via `getter`'s morph into a `Fold`. */ trait GetterLaws[S, A]: def getter: Optic[S, Unit, A, Unit, Direct] diff --git a/laws/src/main/scala/dev/constructive/eo/laws/ModifyLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/ModifyLaws.scala new file mode 100644 index 00000000..a862a295 --- /dev/null +++ b/laws/src/main/scala/dev/constructive/eo/laws/ModifyLaws.scala @@ -0,0 +1,28 @@ +package dev.constructive.eo +package laws + +import _root_.dev.constructive.eo.data.ModifyF + +import optics.Optic +import optics.Optic.* + +/** Law equations for a `Modify[S, A]` — `Optic[S, S, A, A, ModifyF]`. + * + * Ported from Monocle's `monocle.law.SetterLaws`. Modify optics have no `get`, so the four laws + * are modify-only: identity, composition, replace idempotence, and the consistency of `replace` + * with `modify(const a)`. + */ +trait ModifyLaws[S, A]: + def modify: Optic[S, S, A, A, ModifyF] + + def modifyIdentity(s: S): Boolean = + modify.modify(identity[A])(s) == s + + def composeModify(s: S, f: A => A, g: A => A): Boolean = + modify.modify(g)(modify.modify(f)(s)) == modify.modify(f.andThen(g))(s) + + def replaceIdempotent(s: S, a: A): Boolean = + modify.replace(a)(modify.replace(a)(s)) == modify.replace(a)(s) + + def consistentReplaceModify(s: S, a: A): Boolean = + modify.replace(a)(s) == modify.modify(_ => a)(s) diff --git a/laws/src/main/scala/dev/constructive/eo/laws/SetterLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/SetterLaws.scala deleted file mode 100644 index 81b2a578..00000000 --- a/laws/src/main/scala/dev/constructive/eo/laws/SetterLaws.scala +++ /dev/null @@ -1,28 +0,0 @@ -package dev.constructive.eo -package laws - -import _root_.dev.constructive.eo.data.SetterF - -import optics.Optic -import optics.Optic.* - -/** Law equations for a `Setter[S, A]` — `Optic[S, S, A, A, SetterF]`. - * - * Ported from Monocle's `monocle.law.SetterLaws`. Setters have no `get`, so the four laws are - * modify-only: identity, composition, replace idempotence, and the consistency of `replace` with - * `modify(const a)`. - */ -trait SetterLaws[S, A]: - def setter: Optic[S, S, A, A, SetterF] - - def modifyIdentity(s: S): Boolean = - setter.modify(identity[A])(s) == s - - def composeModify(s: S, f: A => A, g: A => A): Boolean = - setter.modify(g)(setter.modify(f)(s)) == setter.modify(f.andThen(g))(s) - - def replaceIdempotent(s: S, a: A): Boolean = - setter.replace(a)(setter.replace(a)(s)) == setter.replace(a)(s) - - def consistentReplaceModify(s: S, a: A): Boolean = - setter.replace(a)(s) == setter.modify(_ => a)(s) diff --git a/laws/src/main/scala/dev/constructive/eo/laws/UnfoldLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/UnfoldLaws.scala new file mode 100644 index 00000000..e969d4d6 --- /dev/null +++ b/laws/src/main/scala/dev/constructive/eo/laws/UnfoldLaws.scala @@ -0,0 +1,50 @@ +package dev.constructive.eo +package laws + +import cats.{Applicative, Functor} + +import optics.{Review, Unfold} +import optics.Optic.* + +/** Law equations for an `Unfold[T, B, F]` — the build-only / many optic (`Optic[Unit, T, Unit, B, + * Forget[F]]` whose real map is `embed: F[B] => T`). + * + * Like [[GetterLaws]], the primary law is *constructor-correctness*: the only observable behaviour + * of a build-only optic is `embed`, so it must equal whatever reference function the caller claims + * the unfold represents. The two coherence laws pin the fused `andThen` members to their + * specification — post-composition through a `Review` re-homes the assembled whole (`(rev ∘ + * u).embed = rev.reverseGet ∘ u.embed`) and pre-composition maps each part (`(u ∘ rev).embed = + * u.embed ∘ map(rev.reverseGet)`) — so an algebra assembled by optic composition cannot drift from + * the functions it was assembled from. + * + * [[vestigialSingleton]] applies only to `Applicative`-carrier unfolds (built via `Unfold.apply`): + * the vestigial read side is `pure(())`, so a `modify` round-trip must equal embedding the + * singleton layer. Pattern-functor unfolds (built via `Unfold.algebra`) have no lawful read side + * at all — their vestigial `to` throws by specification, which is a behaviour check, not a law + * (see `UnfoldSpec`). + */ +trait UnfoldLaws[T, B, F[_]]: + def unfold: Unfold[T, B, F] + + /** The function this unfold is declared to represent — typically the same `F[B] => T` passed to + * `Unfold.apply` / `Unfold.algebra`. + */ + def reference: F[B] => T + + /** `unfold.embed(fb)` equals the reference function applied at `fb`. */ + def embedConsistent(fb: F[B]): Boolean = + unfold.embed(fb) == reference(fb) + + /** `Review(f).andThen(unfold)` re-homes the whole: its embed is `f ∘ embed`. */ + def postComposeCoherent(f: T => Int, fb: F[B]): Boolean = + Review(f).andThen(unfold).embed(fb) == f(unfold.embed(fb)) + + /** `unfold.andThen(Review(g))` maps each part: its embed is `embed ∘ map(g)`. */ + def preComposeCoherent(g: Int => B, fi: F[Int])(using F: Functor[F]): Boolean = + unfold.andThen(Review(g)).embed(fi) == unfold.embed(F.map(fi)(g)) + + /** Vestigial-read degradation for `Applicative` carriers: the `modify` round-trip through the + * vestigial `to = pure(())` equals embedding the singleton layer. + */ + def vestigialSingleton(b: B)(using F: Applicative[F]): Boolean = + unfold.modify(_ => b)(()) == unfold.embed(F.pure(b)) diff --git a/laws/src/main/scala/dev/constructive/eo/laws/data/SetterFLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/ModifyFLaws.scala similarity index 63% rename from laws/src/main/scala/dev/constructive/eo/laws/data/SetterFLaws.scala rename to laws/src/main/scala/dev/constructive/eo/laws/data/ModifyFLaws.scala index 07823f1d..064405ba 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/SetterFLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/ModifyFLaws.scala @@ -1,37 +1,37 @@ package dev.constructive.eo.laws.data -import dev.constructive.eo.data.{Fst, SetterF, Snd} +import dev.constructive.eo.data.{Fst, ModifyF, Snd} import dev.constructive.eo.forgetful.ForgetfulFunctor -/** Carrier-level laws for `SetterF[X, A]`. +/** Carrier-level laws for `ModifyF[X, A]`. * - * `SetterF[X, A]` wraps a pair `(Fst[X], Snd[X] => A)` — the first component carries "outer + * `ModifyF[X, A]` wraps a pair `(Fst[X], Snd[X] => A)` — the first component carries "outer * state", the second a builder closure that eventually produces the focused `A`. Its * `ForgetfulFunctor` instance post-composes the builder with the function argument. * - * Because `SetterF` holds a function, structural `==` cannot witness equality. The laws below + * Because `ModifyF` holds a function, structural `==` cannot witness equality. The laws below * therefore assert extensional equality: after applying a law-relevant transformation, the first * component and the builder's output on a test input must match the expected values. * - * The `ForgetfulTraverse[SetterF, Distributive]` instance is intentionally not exercised here — + * The `ForgetfulTraverse[ModifyF, Distributive]` instance is intentionally not exercised here — * writing a standalone law for the distributive-traverse path requires picking a concrete * distributive functor (`Id` is distributive, so the identity law collapses to a tautology). The - * traverse identity is already exercised through the `SetterLaws` discipline suite that consumes + * traverse identity is already exercised through the `ModifyLaws` discipline suite that consumes * this carrier. */ -trait SetterFLaws[X, A]: +trait ModifyFLaws[X, A]: - /** `map(fa, identity)` produces a SetterF whose builder behaves identically to the input on every + /** `map(fa, identity)` produces a ModifyF whose builder behaves identically to the input on every * `Snd[X]` sample. */ def functorIdentity( fst: Fst[X], fn: Snd[X] => A, x: Snd[X], - )(using FF: ForgetfulFunctor[SetterF]): Boolean = - val fa = SetterF[X, A]((fst, fn)) + )(using FF: ForgetfulFunctor[ModifyF]): Boolean = + val fa = ModifyF[X, A]((fst, fn)) val mapped = FF.map(fa, identity[A]) - mapped.setter._1 == fst && mapped.setter._2(x) == fn(x) + mapped.modifier._1 == fst && mapped.modifier._2(x) == fn(x) /** `map(map(fa, f), g)` is extensionally equal to `map(fa, f andThen g)` — sampled at one * `Snd[X]`. @@ -42,9 +42,9 @@ trait SetterFLaws[X, A]: f: A => A, g: A => A, x: Snd[X], - )(using FF: ForgetfulFunctor[SetterF]): Boolean = - val fa = SetterF[X, A]((fst, fn)) + )(using FF: ForgetfulFunctor[ModifyF]): Boolean = + val fa = ModifyF[X, A]((fst, fn)) val lhs = FF.map(FF.map(fa, f), g) val rhs = FF.map(fa, f.andThen(g)) - lhs.setter._1 == rhs.setter._1 && - lhs.setter._2(x) == rhs.setter._2(x) + lhs.modifier._1 == rhs.modifier._1 && + lhs.modifier._2(x) == rhs.modifier._2(x) diff --git a/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/SetterFTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/ModifyFTests.scala similarity index 70% rename from laws/src/main/scala/dev/constructive/eo/laws/data/discipline/SetterFTests.scala rename to laws/src/main/scala/dev/constructive/eo/laws/data/discipline/ModifyFTests.scala index e4a8c201..2dc49fdd 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/SetterFTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/data/discipline/ModifyFTests.scala @@ -1,26 +1,26 @@ package dev.constructive.eo.laws.data.discipline -import dev.constructive.eo.data.{Fst, SetterF, Snd} +import dev.constructive.eo.data.{Fst, ModifyF, Snd} import dev.constructive.eo.forgetful.ForgetfulFunctor -import dev.constructive.eo.laws.data.SetterFLaws +import dev.constructive.eo.laws.data.ModifyFLaws import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen} import org.typelevel.discipline.Laws -/** Discipline `RuleSet` for [[SetterFLaws]]. */ -abstract class SetterFTests[X, A] extends Laws: - def laws: SetterFLaws[X, A] +/** Discipline `RuleSet` for [[ModifyFLaws]]. */ +abstract class ModifyFTests[X, A] extends Laws: + def laws: ModifyFLaws[X, A] - def setterF(using + def modifyF(using Arbitrary[Fst[X]], Arbitrary[Snd[X]], Cogen[Snd[X]], Arbitrary[A], Cogen[A], - ForgetfulFunctor[SetterF], + ForgetfulFunctor[ModifyF], ): RuleSet = new SimpleRuleSet( - "SetterF", + "ModifyF", "functor identity (extensional)" -> forAll((fst: Fst[X], fn: Snd[X] => A, x: Snd[X]) => laws.functorIdentity(fst, fn, x)), "functor composition (extensional)" -> diff --git a/laws/src/main/scala/dev/constructive/eo/laws/discipline/LensTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/discipline/LensTests.scala index 7f49e803..b2f083af 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/discipline/LensTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/discipline/LensTests.scala @@ -9,7 +9,7 @@ import org.scalacheck.{Arbitrary, Cogen} * * '''Hierarchy:''' inherits the four modify-tier props from [[internal.ReplaceLawsTests]] and adds * the two get/replace round-trip props (`getReplace`, `replaceGet`) that distinguish a Lens from a - * Setter / Traversal. + * Modify / Traversal. */ abstract class LensTests[S, A] extends internal.ReplaceLawsTests[S, A]: def laws: LensLaws[S, A] diff --git a/laws/src/main/scala/dev/constructive/eo/laws/discipline/SetterTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/discipline/ModifyTests.scala similarity index 70% rename from laws/src/main/scala/dev/constructive/eo/laws/discipline/SetterTests.scala rename to laws/src/main/scala/dev/constructive/eo/laws/discipline/ModifyTests.scala index 6b75c665..4a357e5e 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/discipline/SetterTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/discipline/ModifyTests.scala @@ -4,22 +4,22 @@ package discipline import org.scalacheck.{Arbitrary, Cogen} -/** Discipline `RuleSet` for [[SetterLaws]]. +/** Discipline `RuleSet` for [[ModifyLaws]]. * - * '''Hierarchy:''' `Setter` is the pure modify-tier law family — its props are exactly the four + * '''Hierarchy:''' `Modify` is the pure modify-tier law family — its props are exactly the four * shared with `Lens` / `Traversal`. The body adds nothing on top of [[internal.ReplaceLawsTests]]; - * it just declares the parent and renames the resulting ruleSet to "Setter". + * it just declares the parent and renames the resulting ruleSet to "Modify". */ -abstract class SetterTests[S, A] extends internal.ReplaceLawsTests[S, A]: - def laws: SetterLaws[S, A] +abstract class ModifyTests[S, A] extends internal.ReplaceLawsTests[S, A]: + def laws: ModifyLaws[S, A] protected def modifyIdentityFn: S => Boolean = laws.modifyIdentity protected def composeModifyFn: (S, A => A, A => A) => Boolean = laws.composeModify protected def replaceIdempotentFn: (S, A) => Boolean = laws.replaceIdempotent protected def consistentReplaceModifyFn: (S, A) => Boolean = laws.consistentReplaceModify - def setter(using Arbitrary[S], Arbitrary[A], Cogen[A]): RuleSet = + def modify(using Arbitrary[S], Arbitrary[A], Cogen[A]): RuleSet = new DefaultRuleSet( - name = "Setter", + name = "Modify", parent = Some(replaceTier), ) diff --git a/laws/src/main/scala/dev/constructive/eo/laws/discipline/UnfoldTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/discipline/UnfoldTests.scala new file mode 100644 index 00000000..e7d25510 --- /dev/null +++ b/laws/src/main/scala/dev/constructive/eo/laws/discipline/UnfoldTests.scala @@ -0,0 +1,49 @@ +package dev.constructive.eo +package laws +package discipline + +import cats.{Applicative, Functor} +import org.scalacheck.Prop.forAll +import org.scalacheck.{Arbitrary, Cogen} +import org.typelevel.discipline.Laws + +/** Discipline `RuleSet`s for [[UnfoldLaws]]. [[unfold]] is the core set every unfold satisfies + * (`Functor[F]` suffices — pattern-functor algebras included); [[unfoldApplicative]] extends it + * with the vestigial-read degradation law that only `Unfold.apply`-built (Applicative-carrier) + * unfolds can state. + */ +abstract class UnfoldTests[T, B, F[_]] extends Laws: + def laws: UnfoldLaws[T, B, F] + + def unfold(using + Arbitrary[F[B]], + Arbitrary[F[Int]], + Arbitrary[B], + Cogen[T], + Cogen[Int], + Functor[F], + ): RuleSet = + new SimpleRuleSet( + "Unfold", + "embed consistent with reference" -> + forAll((fb: F[B]) => laws.embedConsistent(fb)), + "Review post-compose coherent (f ∘ embed)" -> + forAll((f: T => Int, fb: F[B]) => laws.postComposeCoherent(f, fb)), + "Review pre-compose coherent (embed ∘ map(g))" -> + forAll((g: Int => B, fi: F[Int]) => laws.preComposeCoherent(g, fi)), + ) + + def unfoldApplicative(using + Arbitrary[F[B]], + Arbitrary[F[Int]], + Arbitrary[B], + Cogen[T], + Cogen[Int], + Applicative[F], + ): RuleSet = + new DefaultRuleSet( + "Unfold (Applicative)", + Some(unfold), + "vestigial read degrades to the singleton layer" -> + forAll((b: B) => laws.vestigialSingleton(b)), + ) diff --git a/laws/src/main/scala/dev/constructive/eo/laws/discipline/internal/ReplaceLawsTests.scala b/laws/src/main/scala/dev/constructive/eo/laws/discipline/internal/ReplaceLawsTests.scala index 71efe90e..378f2319 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/discipline/internal/ReplaceLawsTests.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/discipline/internal/ReplaceLawsTests.scala @@ -6,7 +6,7 @@ package internal import org.scalacheck.{Arbitrary, Cogen} import org.typelevel.discipline.Laws -/** Internal parent `RuleSet` for the modify-tier law families — `Setter`, `Lens`, and `Traversal`. +/** Internal parent `RuleSet` for the modify-tier law families — `Modify`, `Lens`, and `Traversal`. * Each of those law sets shares the four "modify-tier" props (modify identity, compose modify, * replace idempotent, consistent replace-modify); the leaf Tests classes pass a parent built here * to their `RuleSet.parents` and only have to spell out the props that are unique to themselves. @@ -15,12 +15,12 @@ import org.typelevel.discipline.Laws * single inheritance hop. Discipline aggregates parent props automatically, so the user-facing * prop names ("modify identity", "compose modify", …) and check predicates are unchanged. * - * Visibility: `private[discipline]`. The leaf Tests classes (`SetterTests`, `LensTests`, + * Visibility: `private[discipline]`. The leaf Tests classes (`ModifyTests`, `LensTests`, * `TraversalTests`) are the only intended consumers — downstream users wire to those. */ abstract private[discipline] class ReplaceLawsTests[S, A] extends Laws: - /** Per-family law projection — `SetterLaws`, `LensLaws`, and `TraversalLaws` all expose the four + /** Per-family law projection — `ModifyLaws`, `LensLaws`, and `TraversalLaws` all expose the four * modify-tier methods this parent depends on. We keep the projection structural-free with * explicit function values so the parent doesn't need to know which leaf trait it sees. */ diff --git a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulFunctorLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulFunctorLaws.scala index 4cbac2a3..c237d954 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulFunctorLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulFunctorLaws.scala @@ -7,13 +7,13 @@ import dev.constructive.eo.forgetful.ForgetfulFunctor * * * `map(id) == id` * `map(g) ∘ map(f) == map(f andThen g)` * - * Holds for every carrier EO uses: `Tuple2`, `Either`, `Affine`, `SetterF`, `Direct`, `Forget[F]`, + * Holds for every carrier EO uses: `Tuple2`, `Either`, `Affine`, `ModifyF`, `Direct`, `Forget[F]`, * `MultiFocus[F]`. The law trait is parameterised so downstream adding a new carrier can witness * its `ForgetfulFunctor` instance here. * - * Equality is structural — if the carrier wraps a function (as `SetterF` does), + * Equality is structural — if the carrier wraps a function (as `ModifyF` does), * discipline-checking this law requires an extensional comparison. See - * [[dev.constructive.eo.laws.data.SetterFLaws]] for that carrier-specific phrasing. + * [[dev.constructive.eo.laws.data.ModifyFLaws]] for that carrier-specific phrasing. */ trait ForgetfulFunctorLaws[F[_, _], X, A]: diff --git a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulTraverseLaws.scala b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulTraverseLaws.scala index 27b309a6..1343d0e4 100644 --- a/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulTraverseLaws.scala +++ b/laws/src/main/scala/dev/constructive/eo/laws/typeclass/ForgetfulTraverseLaws.scala @@ -9,7 +9,7 @@ import dev.constructive.eo.forgetful.ForgetfulTraverse * Id[A]) == fa` because `Id[X] = X`. * * The full `ForgetfulTraverse` family in core has two flavours: `[_ <: Applicative]` (Affine, - * Forget[F], PowerSeries, Direct at Invariant) and `[_ <: Distributive]` (SetterF). We expose the + * Forget[F], PowerSeries, Direct at Invariant) and `[_ <: Distributive]` (ModifyF). We expose the * `[Applicative]` flavour here because `Id`-identity is the widely applicable anchor law; the * Distributive variant collapses to a tautology at `Id`, so witnessing it adds no signal. * 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 dbc540d9..ef742978 100644 --- a/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala +++ b/schemes/src/main/scala/dev/constructive/eo/schemes/Schemes.scala @@ -2,7 +2,7 @@ package dev.constructive.eo package schemes import data.PSVec -import optics.{Getter, Plated, Review} +import optics.{Getter, Plated, Review, Unfold} /** Recursion schemes as composable optics, built on the core optic surface. * @@ -185,6 +185,19 @@ object Schemes: 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. The typed + * pattern-functor path is where a pure `F[A] => A` algebra is fully expressive, because `F`'s + * constructors carry what `PSVec` erases. + */ + 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)`. */ 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 333e5f68..086938d4 100644 --- a/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala +++ b/schemes/src/test/scala/dev/constructive/eo/schemes/SchemesSpec.scala @@ -5,7 +5,7 @@ import io.circe.Json import org.specs2.mutable.Specification import data.PSVec -import optics.{Getter, Plated} +import optics.{Getter, Plated, Review, Unfold} import optics.Optic.* // cross, andThen, get import generics.plate @@ -51,6 +51,19 @@ class SchemesSpec extends Specification: (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)) diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index b2830859..bae7b4e5 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -112,9 +112,9 @@ carries to stay uniform across families. The `loyaltyId` rows are the canonical `customer.loyaltyId: Option[String]` focus (in memory — Avro omits it as a union), Some and None branches. -**Getter / Setter** (`Direct` / `SetterF`) — depth-0/3/6 over `Nested`. Both +**Getter / Modify** (`Direct` / `ModifyF`) — depth-0/3/6 over `Nested`. Both families compose through the fused **`inline` `andThen`** on their concrete -subclasses (`DirectGetter` / `SetterOptic`), so every row builds a *composed* +subclasses (`Getter` / `Modify`), so every row builds a *composed* optic on both sides and dispatches through it once — apples-to-apples with Monocle's composed `Getter`/`Setter`. @@ -125,14 +125,14 @@ Monocle's composed `Getter`/`Setter`. | `_6` | 11.30 | 27.50 | 26.42 | 52.26 | At composition depth both families stay close to the hand-written baseline — a -depth-N chain of `.get` calls, or a nested `copy` for the setter. The lever is +depth-N chain of `.get` calls, or a nested `copy` for the modifier. The lever is `inline` on the same-carrier `andThen`: each compose site splices a *distinct* lambda, so a depth-N chain becomes distinct synthetic methods per level. A plain `def` reuses one shared `andThen$$anonfun$` bytecode across the chain, which C2 reads as recursion and caps (`MaxRecursiveInlineLevel`), leaving the deep tail as virtual `Function1.apply`; splicing distinct lambdas sidesteps that cap with no -JVM flag. Setter additionally sheds its per-hop `SetterF` allocation (the fused -`SetterOptic` writes through `modifyFn` directly: depth-6 800→288 B/op). Monocle +JVM flag. Modify (benchmarked as `SetterBench`; Monocle's family is still `Setter`) additionally sheds its per-hop `ModifyF` allocation (the fused +`Modify` writes through `modifyFn` directly: depth-6 800→288 B/op). Monocle gets the same un-capped inlining from a fresh anonymous class per compose, and trails here (~1.7–2.4× at `_3`/`_6`). **Monocle is faster at the scalar leaf** (`_0`, `order.id`) by a few tenths of a ns — the sub-nanosecond floor where its diff --git a/site/docs/concepts.md b/site/docs/concepts.md index 959193d9..eac845eb 100644 --- a/site/docs/concepts.md +++ b/site/docs/concepts.md @@ -9,7 +9,7 @@ trait Optic[S, T, A, B, F[_, _]]: def from: F[X, B] => T ``` -Every family — Lens, Prism, Iso, Optional, Setter, Getter, Fold, +Every family — Lens, Prism, Iso, Optional, Modify, Getter, Fold, Traversal — is a specialisation of this shape differing only in the **carrier** `F[_, _]`. Composition crosses families by morphing from one carrier to another rather than hand-rolling @@ -58,13 +58,13 @@ this optic have?" | Carrier | Shape | Family | |-----------------|------------------------------------------------|------------------------| -| `Direct` | `A` — identity; no leftover (forgetful functor) | `Iso`, `Getter` | +| `Direct` | `A` — identity; no leftover (forgetful functor) | `Iso`, `Getter`, `Review` | | `Tuple2` | `(X, A)` — both halves always present | `Lens` | | `Either` | `Either[X, A]` — branch present or absent | `Prism` | | `Affine` | `Either[Fst[X], (Snd[X], A)]` | `Optional`, `AffineFold` | | `MultiFocus[F]` | `(X, F[A])` — pair leftover with an `F`-wrapped focus vector | unified successor of `AlgLens[F]` + `Kaleidoscope` + `Grate` + `PowerSeries` + `FixedTraversal[N]`; sub-shapes selected by `F` (`PSVec` ⇒ `Traversal.each`; `Function1[Int, *]` ⇒ `Traversal.{two,three,four}` and `MultiFocus.tuple` / `representable`); `.collectMap` / `.collectList` Kaleidoscope universals — see [MultiFocus](multifocus.md) | -| `Forget[F]` | `F[A]` — a `Foldable`/`Traverse` container | `Fold` | -| `SetterF` | `(Fst[X], Snd[X] => A)` | `Setter` | +| `Forget[F]` | `F[A]` — an `F`-layer with no leftover | `Fold` (read-only, `F: Foldable`), `Unfold` (build-only, `embed: F[B] => T`) | +| `ModifyF` | `(Fst[X], Snd[X] => A)` | `Modify` | What a carrier supports is *exactly* what its typeclass instances provide: @@ -86,13 +86,15 @@ a new carrier means supplying the typeclass instances the operations it wants to support need — not rewriting `Optic` or the existing families. -Two **standalone** types — `Review` and the circe-specific -`JsonTraversal` — deliberately sit *outside* the Optic trait. -Both would have to invent an artificial `to` to satisfy the -trait contract (`Review` has no read; `JsonTraversal` has no -need for `AssociativeFunctor`), and [extending as little as you -need](extensibility.md) is cheaper than fabricating trait -members you won't use. +One **standalone** type — the circe-specific `JsonTraversal` — +deliberately sits *outside* the Optic trait: it has no need for +`AssociativeFunctor`, and [extending as little as you +need](extensibility.md) is cheaper than fabricating trait members +you won't use. (`Review` once sat outside too, on the grounds that +"a pure builder has no `to`" — but with source `Unit` the read side +is exactly as vestigial as `Getter`'s write side, so it was folded +in as a full `Optic`; `Unfold` followed the same pattern on the +many rung.) ## Composition @@ -155,7 +157,7 @@ val mainStreet = wrappedMaybe.andThen(mainOnly) `Composer[Tuple2, Affine]` is one of the stdlib instances; [`dev.constructive.eo.data.Affine`](https://javadoc.io/doc/dev.constructive/cats-eo_3/latest/api/eo/data/Affine$.html) -ships it. Other bridges: `Tuple2 → SetterF`, `Tuple2 → +ships it. Other bridges: `Tuple2 → ModifyF`, `Tuple2 → MultiFocus[F]`, `Either → Affine`, `Either → MultiFocus[F]`, `Affine → MultiFocus[F]`, `Direct → Tuple2`, `Direct → Either`, `Direct → MultiFocus[F]`. @@ -185,9 +187,9 @@ ergonomics of one carrier, extended across all of them. Every edge below is a shipping `Composer[F, G]` given; solid arrows are tier-1 atomic bridges, dashed arrows are tier-2 -transitive derivations via `Composer.chainViaTuple2`. `SetterF` +transitive derivations via `Composer.chainViaTuple2`. `ModifyF` is the only true sink — no outbound `Composer`. `MultiFocus[F]` -is near-terminal: its only outbound bridges are `→ SetterF` +is near-terminal: its only outbound bridges are `→ ModifyF` (write) and a restricted `→ Forget[F]` read-only escape (`multifocus2forget`, available only when `T = Unit`) — so chains effectively land there last. @@ -197,26 +199,30 @@ flowchart LR Direct --> Tuple2 Direct --> Either Direct --> MFocus["MultiFocus[F]"] + Direct --> ForgetF["Forget[F]"] Tuple2 --> Affine - Tuple2 --> SetterF + Tuple2 --> ModifyF Tuple2 --> MFocus Either --> Affine Either --> MFocus Affine --> MFocus - ForgetF["Forget[F]"] --> MFocus - MFocus --> SetterF + ForgetF --> MFocus + MFocus --> ModifyF MFocus -.->|read-only, T=Unit| ForgetF Direct -.->|chainViaTuple2| Affine - Direct -.->|chainViaTuple2| SetterF + Direct -.->|chainViaTuple2| ModifyF Direct -.->|chainViaTuple2| MFocus - SetterF:::sink + ModifyF:::sink classDef sink stroke-dasharray: 0,stroke-width:2px,fill:#eef ``` -`Forget[F]` has one outbound bridge (`→ MultiFocus[F]`) and one -restricted inbound (`MultiFocus[F] → Forget[F]`, the `T = Unit` -read-only escape); chains otherwise reach it via `Fold` at -construction time. +`Forget[F]` has one outbound bridge (`→ MultiFocus[F]`) and two +inbound: `Direct → Forget[F]` (`direct2forget`, for +`F: Applicative: Foldable` — `pure` lifts the read side, a +singleton pick closes the build side, which `Unfold` chains +exercise) and the restricted `MultiFocus[F] → Forget[F]` +(the `T = Unit` read-only escape). Chains otherwise reach it via +`Fold` / `Unfold` at construction time. `MultiFocus[F]` covers five v1 carriers (`AlgLens[F]`, `Kaleidoscope`, `Grate`, `PowerSeries`, `FixedTraversal[N]`) post- fold; sub-shapes are selected by the choice of `F` (e.g. diff --git a/site/docs/cookbook.md b/site/docs/cookbook.md index 1959d29b..beee098e 100644 --- a/site/docs/cookbook.md +++ b/site/docs/cookbook.md @@ -301,7 +301,7 @@ case-class tree, no re-encode, no decoder for the siblings you never read. One vignette in three acts: edit a deep leaf, edit every element of a nested array, then see *why* an edit was a silent no-op. The [Ior failure-flow -diagram](circe.md#failure-flow) covers the full decision tree. +diagram](integrations/circe.md#failure-flow) covers the full decision tree. #### Act 1 — edit one leaf deep in a JSON tree (no decode) @@ -886,7 +886,7 @@ see also [Generics → Composing into pipelines](generics.md#composing-derived-o The next three recipes cover the observability story for the JSON cursor optics: partial-success walks, parse errors surfaced through the same chain, and how to classify the failures by -case. The [Ior failure-flow diagram](circe.md#failure-flow) has +case. The [Ior failure-flow diagram](integrations/circe.md#failure-flow) has the full decision tree. ### Partial-success array walk — `Ior.Both` @@ -1092,7 +1092,7 @@ the kindlings-derived schema entirely. **Source:** cats-eo internal (`AvroPrism`'s triple-input surface, Unit 10). Background framing on the streaming / Kafka use case lives in the -[Avro integration intro](avro.md#why-this-exists). +[Avro integration intro](integrations/avro.md#why-this-exists). ## Further reading @@ -1105,11 +1105,11 @@ Kafka use case lives in the [family taxonomy](optics.md#family-taxonomy) diagram. - [Generics](generics.md) — macro-derived `lens[S](...)` and `prism[S, A]`. -- [Circe integration](circe.md) — cursor-backed JSON optics; - [failure flow](circe.md#failure-flow) for the Ior decision +- [Circe integration](integrations/circe.md) — cursor-backed JSON optics; + [failure flow](integrations/circe.md#failure-flow) for the Ior decision tree. -- [Avro integration](avro.md) — cursor-backed Avro optics; - [failure flow](avro.md#failure-flow) for the schema-driven +- [Avro integration](integrations/avro.md) — cursor-backed Avro optics; + [failure flow](integrations/avro.md#failure-flow) for the schema-driven Ior decision tree. - [Migrating from Monocle](migration-from-monocle.md) — side-by-side translation guide. diff --git a/site/docs/directory.conf b/site/docs/directory.conf index aabea385..15bc4029 100644 --- a/site/docs/directory.conf +++ b/site/docs/directory.conf @@ -6,9 +6,7 @@ laika.navigationOrder = [ multifocus.md schemes.md generics.md - circe.md - avro.md - jsoniter.md + integrations extensibility.md cookbook.md benchmarks.md diff --git a/site/docs/extensibility.md b/site/docs/extensibility.md index e3e12dd4..38d0eaa2 100644 --- a/site/docs/extensibility.md +++ b/site/docs/extensibility.md @@ -15,7 +15,7 @@ trait Optic[S, T, A, B, F[_, _]]: def from: F[X, B] => T ``` -Every built-in family — Lens, Prism, Iso, Optional, Setter, +Every built-in family — Lens, Prism, Iso, Optional, Modify, Traversal, Fold — is a concrete subclass that differs only in which carrier `F[_, _]` it picks. The operations (`.get`, `.modify`, `.modifyA`, `.foldMap`, `.andThen`, …) are @@ -276,7 +276,7 @@ the full tables. - [Concepts](concepts.md) — what a carrier is and which typeclasses unlock which operations. -- [Circe integration](circe.md) — how to *use* the +- [Circe integration](integrations/circe.md) — how to *use* the JsonPrism / JsonTraversal optics described here as a design case study. - [Benchmarks](benchmarks.md) — measured numbers for both diff --git a/site/docs/generics.md b/site/docs/generics.md index a5218b32..233e9708 100644 --- a/site/docs/generics.md +++ b/site/docs/generics.md @@ -178,7 +178,7 @@ left-to-right pipeline instead of nested `copy` calls. **The wire.** Pair a derived optic with a serialization codec and the same `.get` / `.replace` / `.modify` vocabulary spans the gap between your domain types and their on-the-wire form: decode once, transform through optics, -re-encode — or, with [eo-circe](circe.md) / [eo-avro](avro.md), edit the encoded +re-encode — or, with [eo-circe](integrations/circe.md) / [eo-avro](integrations/avro.md), edit the encoded form in place and never fully decode at all. Put together, that turns a request handler into a short pipeline. The @@ -283,9 +283,9 @@ over a recursive ADT. It focuses every field whose type is exactly types stay as leftover skeleton. The derived instance also backs `Plated.everywhere[S]` — a composable -`Setter` that lifts any downstream optic to *every* depth, so +`Modify` that lifts any downstream optic to *every* depth, so `everywhere[S].andThen(prism).modify(f)` rewrites that focus across the -whole tree. See [Optics → Setter](optics.md#setter) and the +whole tree. See [Optics → Modify](optics.md#modify) and the [Cookbook recipe](cookbook.md). ```scala diff --git a/site/docs/index.md b/site/docs/index.md index c993e188..0abbeb88 100644 --- a/site/docs/index.md +++ b/site/docs/index.md @@ -21,8 +21,8 @@ encoded. The Lens / Prism / Traversal vocabulary you'd normally use for in-memory case-class trees is the same vocabulary that lights up when one side of the structure is a JSON byte stream -([eo-jsoniter](jsoniter.md)) or an Apache Avro record on the wire -([eo-avro](avro.md)) or a circe `Json` AST ([eo-circe](circe.md)). +([eo-jsoniter](integrations/jsoniter.md)) or an Apache Avro record on the wire +([eo-avro](integrations/avro.md)) or a circe `Json` AST ([eo-circe](integrations/circe.md)). You get to specify one side of the mirror — the focus, the operation, the path — and let the carrier implement the other. On one side: the bytestream, the wire, the buffered representation. @@ -62,19 +62,20 @@ types alike. - [Concepts](concepts.md) — what an Optic *is*, what a carrier is, and how `Composer` bridges family boundaries. - [Optics reference](optics.md) — one section per family, with - runnable examples. + runnable examples and the compiler-pinned 11-family + [composition matrix](optics.md#composition-matrix). - [MultiFocus](multifocus.md) — the unified successor of five v1 carriers (`AlgLens[F]`, `Kaleidoscope`, `Grate`, `PowerSeries`, `FixedTraversal[N]`); typeclass-gated capability matrix and composability profile. - [Generics](generics.md) — the `lens[S](_.field)` and `prism[S, A]` macros, backed by Hearth. -- [Circe integration](circe.md) — `JsonPrism` / `JsonTraversal`, +- [Circe integration](integrations/circe.md) — `JsonPrism` / `JsonTraversal`, cursor-backed navigation into circe `Json` with no full decode. -- [Avro integration](avro.md) — `AvroPrism` / `AvroTraversal`, +- [Avro integration](integrations/avro.md) — `AvroPrism` / `AvroTraversal`, cursor-backed navigation into Apache Avro `IndexedRecord` with no full decode; binary + JSON wire-format input dual. -- [Jsoniter integration](jsoniter.md) — `JsoniterPrism` / +- [Jsoniter integration](integrations/jsoniter.md) — `JsoniterPrism` / `JsoniterTraversal`, byte-cursor navigation directly into `Array[Byte]` JSON via jsoniter-scala codecs. Read at ~50 ns/op (16× eo-circe), write via splice at ~100 ns/op (14× diff --git a/site/docs/avro.md b/site/docs/integrations/avro.md similarity index 98% rename from site/docs/avro.md rename to site/docs/integrations/avro.md index 27992ab1..c1aad22f 100644 --- a/site/docs/avro.md +++ b/site/docs/integrations/avro.md @@ -34,7 +34,7 @@ representation, modifying only the focused leaf and rebuilding the parents on the way up. The [`OrderAvroBench`](https://github.com/Constructive-Programming/eo/blob/main/benchmarks/src/main/scala/dev/constructive/eo/bench/OrderAvroBench.scala) suite documents the speedup against the kindlings-avro-derivation -codec round-trip — see the [benchmarks page](benchmarks.md) for the +codec round-trip — see the [benchmarks page](../benchmarks.md) for the full table. The codec backend is @@ -565,7 +565,7 @@ For edits that recurse through every nested record rather than sitting at a fixed path — redact a field in every sub-record, rewrite a value at any depth — `Plated[IndexedRecord]` (from `dev.constructive.eo.avro.given`) makes the record tree a recursive -self-traversal. The [`Plated`](cookbook.md) combinators +self-traversal. The [`Plated`](../cookbook.md) combinators (`transform`, `rewrite`, `children`, `universe`) then walk it, stack-safely. The immediate children of a record are its directly-record-valued fields; records nested inside array / map / @@ -573,10 +573,10 @@ union fields are leftover skeleton in this version. This is the Avro mirror of [`Plated[Json]`](circe.md). `Plated.everywhere[IndexedRecord]` is the composable form: an ordinary -`Setter` you can `.andThen` a Lens / Prism onto so a single `.modify` +`Modify` you can `.andThen` a Lens / Prism onto so a single `.modify` rewrites that focus at every depth — composing exactly as it does for [`Plated[Json]`](circe.md). See the circe page and the -[cookbook recipe](cookbook.md) for the runnable `everywhere.andThen(...)` +[cookbook recipe](../cookbook.md) for the runnable `everywhere.andThen(...)` shape. ## When to reach for which @@ -594,7 +594,7 @@ shape. | Parse + edit Avro JSON wire payloads | `AvroPrism.modify(...)` on `String` | For the Kafka end-to-end recipe (read bytes, modify, re-emit), -see the [Cookbook → Kafka payload edit](cookbook.md#kafka-payload-edit). +see the [Cookbook → Kafka payload edit](../cookbook.md#kafka-payload-edit). For the full failure-mode matrix and the per-case behaviour specs, see [`AvroPrismSpec`](https://github.com/Constructive-Programming/eo/blob/main/avro/src/test/scala/dev/constructive/eo/avro/AvroPrismSpec.scala) diff --git a/site/docs/circe.md b/site/docs/integrations/circe.md similarity index 98% rename from site/docs/circe.md rename to site/docs/integrations/circe.md index ba948998..ddc5aac1 100644 --- a/site/docs/circe.md +++ b/site/docs/integrations/circe.md @@ -34,7 +34,7 @@ the whole payload — so the gap grows from ~3× on a tiny record to ~160× on a large one. For a write that touches *every* element of an array, though, both sides are O(elements) and the cursor walk has no edge (it is in fact slightly slower) — reach for the traversal there for composition and diagnostics, not -raw throughput. See the [benchmarks page](benchmarks.md) +raw throughput. See the [benchmarks page](../benchmarks.md) for the full tables. ## JsonPrism @@ -386,7 +386,7 @@ every field named `ssn` at any depth, uppercase every string, round every number. `Plated[Json]` makes `Json` a recursive self-traversal — the immediate children of a node are an array's elements or an object's field values — so the -[`Plated`](cookbook.md) combinators walk the whole document: +[`Plated`](../cookbook.md) combinators walk the whole document: ```scala mdoc:silent import dev.constructive.eo.circe.given @@ -431,7 +431,7 @@ everyString.modify(_.toUpperCase)(doc).noSpacesSortKeys to any depth (`transform` / `everywhere` on a call-stack/heap-machine hybrid, `universe` on a worklist, `rewrite` trampolined through `cats.Eval` so even a long re-fire chain won't overflow), so a deep -document is safe. See the [cookbook Plated recipe](cookbook.md) for the +document is safe. See the [cookbook Plated recipe](../cookbook.md) for the data-type side of the same API. ## When to reach for which diff --git a/site/docs/integrations/directory.conf b/site/docs/integrations/directory.conf new file mode 100644 index 00000000..f2b11c6c --- /dev/null +++ b/site/docs/integrations/directory.conf @@ -0,0 +1,6 @@ +laika.title = Integrations +laika.navigationOrder = [ + circe.md + avro.md + jsoniter.md +] diff --git a/site/docs/jsoniter.md b/site/docs/integrations/jsoniter.md similarity index 99% rename from site/docs/jsoniter.md rename to site/docs/integrations/jsoniter.md index ead0cc8b..a7634db2 100644 --- a/site/docs/jsoniter.md +++ b/site/docs/integrations/jsoniter.md @@ -43,7 +43,7 @@ on the hot path: Numbers from the [`JsoniterBench`](https://github.com/Constructive-Programming/eo/blob/main/benchmarks/src/main/scala/dev/constructive/eo/bench/JsoniterBench.scala) -suite — see [benchmarks → JsoniterBench](benchmarks.md) for the full +suite — see [benchmarks → JsoniterBench](../benchmarks.md) for the full table with confidence intervals and caveats. The traversal speedup narrows because per-element decode + array allocation accumulates; larger arrays push it back up. Writes don't degrade vs reads on the diff --git a/site/docs/migration-from-monocle.md b/site/docs/migration-from-monocle.md index dd8e0a09..501e7c71 100644 --- a/site/docs/migration-from-monocle.md +++ b/site/docs/migration-from-monocle.md @@ -1,57 +1,107 @@ # Migrating from Monocle -A side-by-side translation table for the common Monocle idioms, -plus a note on where EO diverges. +[Monocle](https://www.optics.dev/Monocle/) is a mature, elegant +optics library and the reference point for optics on Scala. If it is +serving you well, there is no urgency to move. This page is for the +cases where you want what cats-eo optimises for — and a map of what +changes (and what doesn't) when you do. -## Cheat sheet +## Why migrate? -| Monocle | cats-eo | -|----------------------------------------------------|-----------------------------------------------------| -| `Lens[S, A](get)(a => s => …)` | `Lens[S, A](get, (s, a) => …)` | -| `GenLens[S](_.field)` | `lens[S](_.field)` (from `dev.constructive.eo.generics`) | -| `GenLens[S](_.a).andThen(GenLens[S](_.b)).andThen(...)` — N hand-composed GenLenses | `lens[S](_.a, _.b, ...)` — one varargs call; full-cover upgrades to `BijectionIso` automatically (no Monocle equivalent) | -| `Prism[S, A](_.some)(identity)` | `Prism.optional[S, A](_.some, identity)` | -| `GenPrism[S, A]` | `prism[S, A]` (from `dev.constructive.eo.generics`) | -| `Iso[S, A](f)(g)` | `Iso[S, S, A, A](f, g)` | -| `Optional[S, A](_.some)(a => s => …)` | `Optional[S, S, A, A, Affine](getOrModify, rg)` | -| *(no standalone equivalent — Monocle reaches for `Optional.getOption`)* | `AffineFold(p => ...)` / `AffineFold.select(p)` / `AffineFold(optic.getOption)` for a read-only view of an Optional/Prism — read-only 0-or-1 focus, `T = Unit` forbids `.modify` | -| *(no direct equivalent — algebraic lenses + Kaleidoscopes are not in Monocle)* | `MultiFocus.fromLensF` / `fromPrismF` / `fromOptionalF` — classifier-shaped optic over `F[A]` focus, plus `.collectMap` / `.collectList` aggregation universals; see [Optics → MultiFocus](optics.md#multifocus) | -| `Setter[S, A](f => s => …)` | `Setter[S, S, A, A](f => s => …)` | -| `Fold.fromFoldable[List, Int]` | `Fold[List, Int]` (with `cats.instances.list.given`)| -| `Traversal.fromTraverse[List, Int]` | `Traversal.each[List, Int]` (`Traversal.pEach[List, Int, Int]` for the polymorphic-write variant) | -| `monocle.function.Plated[A]` + `transform` / `rewrite` / `universe` / `children` | `Plated[S]` — derive with `plate[S]` (from `dev.constructive.eo.generics`) or hand-write with `Plated.fromChildren`; same combinator names, plus `Plated.everywhere[S]` as a composable Setter (no Monocle equivalent) | -| `lens.andThen(otherLens)` | `lens.andThen(otherLens)` — same | -| `lens.andThen(optional)` | `lens.andThen(optional)` — cross-carrier `.andThen` lifts via `Composer[Tuple2, Affine]` | -| `traversal.andThen(lens)` | `traversal = Traversal.each[…]; traversal.andThen(lens)` — auto-morph via `Composer[Tuple2, PowerSeries]` | -| `lens.get(s)` | `lens.get(s)` — same | -| `lens.replace(a)(s)` / `lens.set(a)(s)` | `lens.replace(a)(s)` — same | -| `lens.modify(f)(s)` | `lens.modify(f)(s)` — same | -| `prism.getOption(s)` | `prism.getOption(s)` — on the concrete returned class; `prism.to(s).toOption` through the generic trait | -| `prism.reverseGet(a)` | `prism.reverseGet(a)` — same | -| `optional.getOption(s)` | `optional.getOption(s)` — generic `.getOption` extension on any `Optic[_, _, _, _, Affine]` (Optional and AffineFold both ship it) | -| `traversal.modify(f)(xs)` | `traversal.modify(f)(xs)` — same | -| `fold.foldMap(f)(xs)` | `fold.foldMap(f)(xs)` — same | +cats-eo and Monocle care about different things. Monocle's design +prizes a clean, minimal core: eight classic optics related by an +inheritance hierarchy, so the library reads as beautifully as the +code you write with it. cats-eo is built for **performance and +industrial application**, and it accepts internal complexity to get +there. The library's own source is not the most elegant code you +will ever read — existential carriers, fused composition overloads, +opaque types, hand-tuned array machines — but every one of those +choices buys something at *your* call site: + +- **Efficiency.** Optic operations compile down to plain function + composition wherever possible. The fused `inline` composers mean a + deeply composed optic dispatches like hand-written code instead of + a chain of allocated closures — on the [benchmarks](benchmarks.md), + composed getters and modifies run ~2× faster than the equivalent + Monocle composition at depth 6, and `each`-style traversals reach + ~3× on realistic collection sizes (Monocle wins some single-hop + cells; the gap is a composition-depth story). When an optic sits on + a hot path, this is the difference that pays for the migration. +- **Stack safety.** Deep or degenerate structures are a fact of + industrial data — a 100k-deep JSON spine, a recursive ADT from a + parser. cats-eo's recursive machinery (`Plated`, the recursion + schemes) runs on heap-backed machines and is safe at depths where + simple recursion overflows. +- **Interoperability.** The same Lens / Prism / Traversal vocabulary + you use on case classes extends to wire formats: circe `Json` + ASTs, Apache Avro records, and raw jsoniter byte buffers — read + and edit encoded data without a decode/re-encode round trip. The + Monocle ecosystem prefers to leave format-specific tooling to the + formats themselves; cats-eo treats it as part of the optics story. +- **Compositional reach.** Every family composes with every other + family through one `.andThen`, and the result is compiler-pinned: + an 11-family [composition matrix](optics.md#composition-matrix) + asserts every pair either composes (without type ascriptions or + imports) or fails to compile by design. The family space itself is + larger: standalone read-only, build-only, and write-only citizens + (AffineFold, Review, Unfold, Modify), multi-focus shapes (Grate, + Kaleidoscope, algebraic lenses), and recursion schemes + (`cata` / `ana` / `hylo`) that *are* optics and drop into the same + pipelines. + +The trade is real in both directions: Monocle gives you a smaller, +smoother surface; cats-eo gives you throughput, depth, and reach. + +## What stays the same + +Your day-to-day vocabulary ports almost verbatim — `get`, `replace`, +`modify`, `foldMap`, `getOption`, `reverseGet`, and `.andThen` +composition all read the same. `GenLens[S](_.field)` becomes +`lens[S](_.field)` (from `dev.constructive.eo.generics`), and +Monocle's polymorphic `PLens` / `PPrism` / `PIso` shapes map onto +eo's `pLens` / polymorphic constructors one-for-one. + +Law testing ports directly too. Monocle ships +`monocle-law` with discipline rule-sets; `cats-eo-laws` follows the +exact same `checkAll` pattern, so your test setup is an import swap: + +```scala +libraryDependencies += "dev.constructive" %% "cats-eo-laws" % "@VERSION@" % Test +``` + +```scala +// before: import monocle.law.discipline.LensTests +import dev.constructive.eo.laws.discipline.LensTests + +checkAll("Lens[Person, Int]", LensTests[Person, Int](ageL).lens) +``` -## Where EO diverges +Every public optic family has a matching `FooLaws` / `FooTests` +pair, including the families Monocle prefers not to ship (AffineFold, +Review, Unfold, Modify, MultiFocus). -### Polymorphic constructors +## Where eo differs at the call site -Every EO family ships a monomorphic `Type[S, A]` constructor -and a polymorphic `pType[S, T, A, B]` counterpart. Monocle only -has the monomorphic forms on the top-level `Lens` / `Prism` / -`Iso` objects and exposes the polymorphic shapes through -`PLens` / `PPrism` / `PIso`. +### Bring your own optic -### Cross-family composition: `.andThen` auto-morphs +Both libraries give you one `.andThen` across families, so that is +not the difference. Monocle composes through its inheritance +hierarchy: every optic *is* a weaker optic, so composition meets at +the common ancestor — an elegant design, and it works because the +hierarchy is closed and known up front. cats-eo chose not to have an +extension hierarchy at all, and *that* is what changes in practice: +composition is driven by typeclass instances over the **carrier** +(`AssociativeFunctor[F, X, Y]` for a shared carrier, a summoned +`Morph[F, G]` / `Composer[F, G]` across carriers), not by where a +family sits in a subtype tree. -Monocle's `andThen` has implicit overloads for every optic -pair. cats-eo keeps `Optic.andThen` carrier-aware: same-carrier -composition goes through `AssociativeFunctor[F, X, Y]`, and -cross-carrier composition routes through a summoned -`Morph[F, G]` (which picks up a `Composer[F, G]` or -`Composer[G, F]`) to lift both sides under a shared carrier. -The upshot at the call site: the same `.andThen` works whether -the two optics share `F` or not: +The payoff is for optics that come from *outside* the shipped +families. A custom optic needs no blessed place in any hierarchy: +implement the `Optic` trait, put the carrier instances in scope, and +it composes with everything reachable through the bridges — bring +your own optic. [Extensibility](extensibility.md) walks through +shipping a domain-tuned optic end to end. The same machinery is what +makes the stock families compose: ```scala mdoc:silent import dev.constructive.eo.data.Affine @@ -74,67 +124,134 @@ val appConfig = val appTimeout = appConfig.andThen(timeoutOpt) ``` -The payoff: composition is carrier-level (one `Composer` per -pair of carriers), not family-level (one `andThen` per pair of -optics). Adding a new optic family means supplying carrier -instances; the cross-family bridges come for free. +Composition is carrier-level (one `Composer` per pair of carriers), +not family-level — so a new family, yours or ours, supplies carrier +instances and inherits the cross-family bridges for free. -### Getter / Setter don't compose via `.andThen` +### The one-way side, built out in both directions -Getter's `T = Unit` and Setter's `SetterF` carrier have no -`AssociativeFunctor` instance. Compose a Lens chain (Tuple2 -carrier) and reach for `.get` / `.modify` at the leaf -instead. See [Optics → Getter](optics.md#getter) for the -workaround. +Read-only collapse is common ground, not a difference: compose a +chain that touches a Getter and both libraries land you on the +read-only join — Monocle through its hierarchy, cats-eo through +`ReadCompose` (`lens.andThen(getter)` → Getter, +`prism.andThen(getter)` → AffineFold, `traversal.andThen(getter)` → +Fold). Where cats-eo invests further is the **write side and +beyond**: -### Traversal carrier +- The build/write-only direction is fully populated — `Modify` + (write-only), `Review` and `Unfold` (build-only, one focus and + many) are standalone, composable citizens, with a *fallible* tier + (writes and builds that can reject, with accumulating failures) + planned next. The [composition matrix](optics.md#composition-matrix) + pins how every pair behaves, including the cells that are void by + design (`getter.andThen(modify)` — nothing to write through). +- A preliminary integration with **recursion schemes**: cats-eo is + attempting to present recursion schemes as optics *themselves* — + `cata` is a Getter, `ana` a Review, `hylo` a fused Getter — so that + genuinely complex algorithms over trees and recursive data + structures sit in the same pipeline as ordinary getters and + modifiers, and the whole thing remains **one optic from start to + finish**. The direction this is heading: an HTTP server that + receives a JSON request, writes to a PostgreSQL database, and logs + to an Avro-encoded Kafka topic — expressed as a single composed + optic. -cats-eo's `Traversal` is a single carrier: +That ambition is why the family roster below is as large as it is. -- `Traversal.each[F, A]` / `pEach[F, A, B]` — carrier - `MultiFocus[PSVec]`. Supports `.modify`, `.foldMap`, `.modifyA`, - and `.andThen` with downstream optics. Pays a small constant- - factor overhead over the naive map path; see the - [PowerSeries benchmark notes](https://github.com/Constructive-Programming/eo/blob/main/benchmarks/README.md#interpreting-powerseries-numbers) - for the cost breakdown. +### A larger family space -### JsonPrism has no Monocle equivalent +Beyond the classic eight, cats-eo ships families Monocle prefers to +express through other means or leave to dedicated libraries: -Monocle's `monocle-circe` module only provides a -`Prism[Json, A]` and deep optics through that Prism — it still -forces a full decode of the focused `A`. cats-eo's `JsonPrism` -/ `JsonTraversal` walk circe's `JsonObject` representation -directly, avoiding the intermediate Codec round-trip at every -level of the path. See [Circe integration](circe.md). +- **AffineFold** — a standalone read-only 0-or-1 focus (where + Monocle reaches for `Optional.getOption`), with `T = B = Unit` so + the type itself forbids writing through it. +- **Review and Unfold** — standalone build-only optics (Monocle + expresses building through `Iso.reverseGet` / `Prism.reverseGet`). + `Unfold[T, B, F]` (`embed: F[B] => T`) carries recursion-scheme + algebras and aggregations as composable citizens. +- **Recursion schemes as optics** — `Schemes.cata` is a `Getter`, + `ana` a `Review`, `hylo` a fused `Getter`, all stack-safe; they + compose into ordinary optic pipelines. Monocle prefers to leave + recursion schemes to dedicated libraries (droste); see + [Recursion schemes](schemes.md). +- **MultiFocus shapes** — Grate-style fixed-shape rewrites, + Kaleidoscope aggregation, algebraic lenses; see + [MultiFocus](multifocus.md). +- **Full-cover macro upgrade** — `lens[S](_.a, _.b, ...)` in one + varargs call; when the selectors cover every field the result + upgrades to a `BijectionIso` automatically (a shape Monocle + prefers to leave to N hand-composed lenses). -### Plated is stack-safe and composes as an optic +### Traversal carrier -Monocle's `monocle.function.Plated` overflows the stack on deep -trees — `transform` and `universe` `StackOverflowError` on a -degenerate spine (see the [benchmarks](benchmarks.md)). cats-eo's -`Plated` clears a 100k-deep spine: `transform` / `everywhere` run on a -call-stack/heap-machine hybrid, the reads on a worklist, and `rewrite` -trampolines through `cats.Eval` (so even a long re-fire chain is safe). -cats-eo also adds `everywhere[S]`, a recursive rewrite -exposed as a composable `Setter` — `everywhere.andThen(prism).modify(f)` -applies an ordinary optic at every depth — which Monocle has no -equivalent for. See the [cookbook recipe](cookbook.md). +cats-eo's `Traversal.each[F, A]` / `pEach[F, A, B]` ride the +`MultiFocus[PSVec]` carrier — `.modify`, `.foldMap`, `.modifyA`, and +downstream `.andThen` all supported. It pays a small constant factor +over a hand-written `map` (see the +[PowerSeries benchmark notes](https://github.com/Constructive-Programming/eo/blob/main/benchmarks/README.md#interpreting-powerseries-numbers)) +and wins it back at composition depth. -## Discipline law instances +### JSON, Avro, and raw bytes -Downstream projects can reuse the same `checkAll` pattern they -know from cats. `cats-eo-laws` ships the rule-sets: +[circe-optics](https://github.com/circe/circe-optics) — the +circe-maintained Monocle companion — gives you `JsonPath` optics +over the `Json` AST, and it does that job well. cats-eo's +[circe integration](integrations/circe.md) covers the same AST +territory and adds multi-field foci (`fields(_.a, _.b)` as a +NamedTuple focus) and an **observable failure channel**: misses and +decode failures surface as `Ior` values you can inspect, where +`JsonPath` prefers the simplicity of `Option` (a miss and a +type-mismatch read the same). From there, the same optic vocabulary +extends where the Monocle ecosystem prefers not to go: Apache Avro +`IndexedRecord`s ([eo-avro](integrations/avro.md)) and raw +`Array[Byte]` JSON via jsoniter-scala +([eo-jsoniter](integrations/jsoniter.md)) — pinpoint reads and +splice-writes on encoded data with no full decode/re-encode cycle. -```scala -libraryDependencies += "dev.constructive" %% "cats-eo-laws" % "@VERSION@" % Test -``` +### Plated is stack-safe and composes as an optic -```scala -import dev.constructive.eo.laws.discipline.LensTests +Monocle's `monocle.function.Plated` keeps its recursion simple and +direct — which reads beautifully, but overflows the stack on +degenerate spines (`transform` / `universe` on a deep chain; see the +[benchmarks](benchmarks.md)). cats-eo's `Plated` trades that +simplicity for stack safety: it clears a 100k-deep spine — +`transform` / `everywhere` on a call-stack/heap-machine hybrid, the +reads on a worklist, `rewrite` trampolined through `cats.Eval`. +cats-eo also exposes `everywhere[S]` as a composable `Modify` — +`everywhere.andThen(prism).modify(f)` applies an ordinary optic at +every depth, a combinator Monocle's Plated chooses not to expose. +See the [cookbook recipe](cookbook.md). -checkAll("Lens[Person, Int]", LensTests[Person, Int](ageL).lens) -``` +## Cheat sheet -Every public optic family has a matching `FooLaws` / -`FooTests` pair. See the `laws/src/main/scala/eo/laws/` tree -for the full list. +| Monocle | cats-eo | +|----------------------------------------------------|-----------------------------------------------------| +| `Lens[S, A](get)(a => s => …)` | `Lens[S, A](get, (s, a) => …)` | +| `PLens[S, T, A, B](get)(set)` | `Lens.pLens[S, T, A, B](get, enplace)` — every family has a polymorphic `pType` constructor | +| `GenLens[S](_.field)` | `lens[S](_.field)` (from `dev.constructive.eo.generics`) | +| `GenLens[S](_.a).andThen(GenLens[S](_.b)).andThen(...)` — N hand-composed GenLenses | `lens[S](_.a, _.b, ...)` — one varargs call; full-cover upgrades to `BijectionIso` automatically | +| `Prism[S, A](_.some)(identity)` | `Prism.optional[S, A](_.some, identity)` | +| `GenPrism[S, A]` | `prism[S, A]` (from `dev.constructive.eo.generics`) | +| `Iso[S, A](f)(g)` | `Iso[S, S, A, A](f, g)` | +| `Optional[S, A](_.some)(a => s => …)` | `Optional[S, S, A, A, Affine](getOrModify, rg)` | +| `optional.getOption` used as a read-only view | `AffineFold(p => ...)` / `AffineFold.select(p)` / `AffineFold(optic.getOption)` — a standalone read-only 0-or-1 family, `T = Unit` forbids `.modify` | +| *(Monocle prefers to keep its surface to the classic optics)* | `MultiFocus.fromLensF` / `fromPrismF` / `fromOptionalF` — classifier-shaped optic over an `F[A]` focus, plus `.collectMap` / `.collectList` aggregation universals; see [Optics → MultiFocus](optics.md#multifocus) | +| `Setter[S, A](f => s => …)` | `Modify[S, S, A, A](f => s => …)` | +| `Iso.reverseGet` / `Prism.reverseGet` as the build path | `Review[S, A](build)` (build-only, one focus) and `Unfold[T, B, F]` (build-only, many: `embed: F[B] => T`); see [Optics → Review](optics.md#review) / [Unfold](optics.md#unfold) | +| *(recursion schemes via droste)* | `Schemes.cata` / `ana` / `hylo` — stack-safe, returned as composable optics; see [Recursion schemes](schemes.md) | +| `Fold.fromFoldable[List, Int]` | `Fold[List, Int]` (with `cats.instances.list.given`)| +| `Traversal.fromTraverse[List, Int]` | `Traversal.each[List, Int]` (`Traversal.pEach[List, Int, Int]` for the polymorphic-write variant) | +| `monocle.function.Plated[A]` + `transform` / `rewrite` / `universe` / `children` | `Plated[S]` — derive with `plate[S]` (from `dev.constructive.eo.generics`) or hand-write with `Plated.fromChildren`; same combinator names, plus `Plated.everywhere[S]` as a composable Modify | +| `monocle.law.discipline.LensTests` | `dev.constructive.eo.laws.discipline.LensTests` — same checkAll pattern, every family covered | +| `lens.andThen(otherLens)` | `lens.andThen(otherLens)` — same | +| `lens.andThen(optional)` | `lens.andThen(optional)` — cross-carrier `.andThen` lifts via `Composer[Tuple2, Affine]` | +| `traversal.andThen(lens)` | `traversal.andThen(lens)` — auto-morph via the carrier bridges | +| `lens.get(s)` | `lens.get(s)` — same | +| `lens.replace(a)(s)` / `lens.set(a)(s)` | `lens.replace(a)(s)` — same | +| `lens.modify(f)(s)` | `lens.modify(f)(s)` — same | +| `prism.getOption(s)` | `prism.getOption(s)` — on the concrete returned class; `prism.to(s).toOption` through the generic trait | +| `prism.reverseGet(a)` | `prism.reverseGet(a)` — same | +| `optional.getOption(s)` | `optional.getOption(s)` — generic `.getOption` extension on any `Optic[_, _, _, _, Affine]` (Optional and AffineFold both ship it) | +| `traversal.modify(f)(xs)` | `traversal.modify(f)(xs)` — same | +| `fold.foldMap(f)(xs)` | `fold.foldMap(f)(xs)` — same | diff --git a/site/docs/multifocus.md b/site/docs/multifocus.md index a5f9464b..629c837e 100644 --- a/site/docs/multifocus.md +++ b/site/docs/multifocus.md @@ -196,7 +196,7 @@ the choice without cluttering the discipline surface. `MultiFocus[F]` has shipped inbound bridges from every classical read-write family (conditional on `F`'s typeclass set) and two -outbound bridges: `→ SetterF` (write) and a restricted `→ Forget[F]` +outbound bridges: `→ ModifyF` (write) and a restricted `→ Forget[F]` read-only escape (`multifocus2forget`, available only when `T = Unit`). The remaining outbound directions are **structurally rejected** rather than absent — see @@ -243,29 +243,29 @@ specialised by `F`: `MultiFocusPSMaybeHit` (Prism / Optional inners skip the per-element wrapper allocation). -### Outbound — `SetterF` and a read-only `Forget[F]` escape +### Outbound — `ModifyF` and a read-only `Forget[F]` escape ```scala mdoc:silent import dev.constructive.eo.compose.Composer -import dev.constructive.eo.data.SetterF +import dev.constructive.eo.data.ModifyF -val setter = summon[Composer[MultiFocus[List], SetterF]].to(listMF) +val modify = summon[Composer[MultiFocus[List], ModifyF]].to(listMF) ``` ```scala mdoc -setter.modify(_ * 2)(List(1, 2, 3)) +modify.modify(_ * 2)(List(1, 2, 3)) ``` -`multifocus2setter[F: Functor]` — closes the U → N gap for both -the prior `kaleidoscope2setter` and the latent never-shipped -`alg2setter`. Like every other `Composer[X, SetterF]`, this does NOT -enable `multiFocus.andThen(setter)` directly: cross-carrier `.andThen` -goes through `AssociativeFunctor[F]` on a single carrier, and SetterF +`multifocus2modify[F: Functor]` — closes the U → N gap for both +the prior v1 `kaleidoscope2setter` and the latent never-shipped +`alg2setter`. Like every other `Composer[X, ModifyF]`, this does NOT +enable `multiFocus.andThen(modify)` directly: cross-carrier `.andThen` +goes through `AssociativeFunctor[F]` on a single carrier, and ModifyF deliberately doesn't ship one (the deferred-modify semantic doesn't fit `composeTo` / `composeFrom`). The morph value lives at the morph -site, not at the chain site. Same-carrier `setter.andThen(setter)` -*does* work — see the [Setter section](optics.md#setter) for the -`AssociativeFunctor[SetterF]` instance shipped in `SetterF.scala`. +site, not at the chain site. Same-carrier `modify.andThen(modify)` +*does* work — see the [Modify section](optics.md#modify) for the +`AssociativeFunctor[ModifyF]` instance shipped in `ModifyF.scala`. The second outbound bridge, `multifocus2forget[F]`, expresses a `MultiFocus[F]`-carrier optic as a read-only `Forget[F]` — discard the @@ -366,13 +366,15 @@ on the worktree branch that landed it: See [`docs/research/2026-04-29-powerseries-fold-spike.md`](https://github.com/Constructive-Programming/eo/blob/main/docs/research/2026-04-29-powerseries-fold-spike.md). - **FixedTraversal `[N]` fold** — `Traversal.{two,three,four}` rerouted through `MultiFocus[Function1[Int, *]]`; the FT-shape gains the - inbound `Iso ↪`, outbound `↪ SetterF`, and same-carrier + inbound `Iso ↪`, outbound `↪ ModifyF`, and same-carrier `.andThen` from the unified MF carrier — three new compositions the user can write today that pre-fold were all U. See [`docs/research/2026-04-29-fixedtraversal-fold-spike.md`](https://github.com/Constructive-Programming/eo/blob/main/docs/research/2026-04-29-fixedtraversal-fold-spike.md). -The composition gap analysis tracks the matrix collapse cell by -cell: +The current, compiler-pinned composition matrix (11 families, 121 +cells) lives in +[Optics → Composition matrix](optics.md#composition-matrix); the +historical gap analysis that tracked the fold cell by cell is [`docs/research/2026-04-23-composition-gap-analysis.md`](https://github.com/Constructive-Programming/eo/blob/main/docs/research/2026-04-23-composition-gap-analysis.md). The pre-spike analysis of `MultiFocus[List]` vs PowerSeries on the traversal-shape common case (1.5–2.6× slower, hence both carriers diff --git a/site/docs/optics.md b/site/docs/optics.md index 41bcb7fd..77683ecd 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -7,62 +7,122 @@ Scaladoc. ## Family taxonomy Every family is a specialisation of the same `Optic[S, T, A, B, F]` -trait, differing only in the carrier `F[_, _]`. The diagram is a -composition lattice: an edge `A → B` means *every `A` is a `B`*, so -composing two optics lands on their **join** — the lowest node both -reach by following edges down. `Iso.andThen(Lens) = Lens`; -`Lens.andThen(Prism)` lands on the `Affine` carrier; a read-only chain -lands in the single-direction group. - -```mermaid -flowchart TD - subgraph bidir["Bi-directional — read and write"] - Iso --> Lens - Iso --> Prism - Lens --> Affine - Prism --> Affine - Affine --> MultiFocus["MultiFocus[F]"] - Iso --> MultiFocus - end - - subgraph single["Single direction"] - Getter["Getter — read-only"] - Setter["Setter — write-only"] - Review["Review — build-only"] - end - - Iso --> Getter - Lens --> Getter - MultiFocus --> Setter - - click Iso "#iso" - click Lens "#lens" - click Prism "#prism" - click Affine "#affine" - click MultiFocus "#multifocus" - click Getter "#getter" - click Setter "#setter" - click Review "#review" -``` +trait, differing only in the carrier `F[_, _]` — and the family space +is **three-axis**: + +- **focus nature** — how `to` lands the focus: *total* (always + there), *fallible* (may miss), or *multiple* (an `F`-layer of + foci). This is exactly the lattice `ReadCompose` joins along. +- **source nature** — what `from` needs to rebuild the source: + *total* (no context — a bijection or a `mend`), *contextual* + (carries the leftover `X`), or *fallible* (the rebuild itself can + reject). The fallible row — and a `WriteCompose` join mirroring + `ReadCompose` — is not shipped yet: it is the territory of the + failure-typed-build (**BiAffine**) plan. +- **capability** — which sides exist at all: read-only (top layer), + read-write (middle), write/build-only (bottom). + +![The three-axis optic family taxonomy: focus nature × source nature × capability](static/optic-taxonomy-3d.svg) How to read it: -- **Same-family compose** stays in that family: `Lens ∘ Lens = Lens`, - `Prism ∘ Prism = Prism`, `Iso ∘ Iso = Iso`. -- **Cross-family compose** walks down from each input to where they - meet: `Lens ∘ Prism` → `Affine`; `Iso ∘ Setter` → `Setter`. -- **The bi-directional spine** (Iso, Lens, Prism, Affine, MultiFocus) - carries both a read and a write side. **Single-direction optics** - keep only one: Getter reads, Setter writes, Review builds. -- Composing a bi-directional optic into Getter or Setter drops the - other side — the result is single-direction. +- The **top layer** is read-only: Getter (total), AffineFold + (fallible), Fold (multiple), each drawn as a bar **spanning the + whole source axis**. That span is honest, not decorative: read-only + optics pin their write side to `Unit` — the *terminal* type, not + the bottom type `Nothing` — and a function into `Unit` exists + uniformly for every input, so the vestigial `from` is vacuously + satisfied at *every* source nature. (Had `B` been `Nothing`, no + `from` could exist at all.) It is the exact dual of `Modify` + spanning the focus axis below. Any chain that touches a read-only + optic projects **up** onto this layer, landing at the join of the + focus natures — `lens ∘ getter` → Getter, `prism ∘ getter` → + AffineFold, `traversal ∘ getter` → Fold. That join is exactly what + `ReadCompose` computes. +- The **middle layer** is the full grid, and with these axes it is + *completely inhabited* — every cell is shipped or planned, no + accidental holes. Iso is total in both focus and source; Lens reads + totally but rebuilds contextually; Prism reads fallibly but mends + totally; Optional is fallible-focus, contextual-source (the + `Affine` carrier). Traversal spans **both** source natures at + multiple focus: the fixed-shape/Grate flavour + (`MultiFocus[Function1]`, `Traversal.{two,three,four}`) rebuilds + *totally* via `tabulate`, while `each` (`MultiFocus[PSVec]`) + rebuilds *contextually*, keeping the structural skeleton. +- The **bottom layer** is write/build-only — but unlike the top + layer it is a **full plane**, not a rail: a write-only optic's + `from` is real (only its `to` is vestigial), so both axes still + apply, and each family sits **directly below the read-write cells + whose build half it is**. Review spans the cells below *both* Iso + and Prism — a `mend` is total, so the two share one build half; + Unfold below Traversal's total flank (multiple focus: + `embed: F[B] => T`, Fold's mirror on the same `Forget[F]` + carrier); `Modify` is the whole contextual row — it *is* the + contextual write half of Lens / Optional / `each`, never observing + the focus shape it lands on; fallible build is the planned fallible + row. Build-side composition projects **down** onto this layer + (`iso ∘ review` → Review, `review ∘ unfold` → Unfold, + `lens ∘ modify` → Modify). +- The **tan cells are planned, not missing by accident** — they are + the fallible-source row: *fallible write* (a smart-constructor + replace that can reject), **BiAffine** (fallible in both focus and + source), and *fallible each* (per-element write failures, the shape + the Avro integration already runs on read). Together with the + `WriteCompose` join they are scoped to the failure-typed-build + spike + ([`docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md`](https://github.com/Constructive-Programming/eo/blob/main/docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md)). `Affine` is the carrier shared by `Optional` (read and write) and `AffineFold` (read-only). `MultiFocus[F]` is the multi-focus carrier; its sub-shapes (PowerSeries, Grate, Kaleidoscope, `AlgLens[F]`) are -selected by `F`. The full cell-by-cell composition matrix lives in -[`docs/research/2026-04-23-composition-gap-analysis.md`](https://github.com/Constructive-Programming/eo/blob/main/docs/research/2026-04-23-composition-gap-analysis.md) -— the lattice above is its geometric view. +selected by `F`. `Forget[F]` is the one-way many carrier shared by +`Fold` (read-only) and `Unfold` (build-only). + +### Composition matrix + +The full 11-family grid. Every cell is pinned by +[`CompositionMatrixSpec`](https://github.com/Constructive-Programming/eo/blob/main/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala): +an inhabited cell composes via plain `.andThen` with **no expected-type +ascription and no `given` imports**, landing at the family shown; ∅ +cells do not compile, **by design** (writing through a read-only optic, +reading through a build-only one, building through a write-incapable +one). 87 of 121 cells compose; 34 are void. + +| outer ∘ inner | Iso | Lens | Prism | Optional | Traversal | Getter | AffineFold | Fold | Modify | Review | Unfold | +|---------------|-----|------|-------|----------|-----------|--------|------------|------|--------|--------|--------| +| **Iso** | Iso | Lens | Prism | Optional | Traversal | Getter | AffineFold | Fold | Modify | Review | Unfold | +| **Lens** | Lens | Lens | Optional | Optional | Traversal | Getter | AffineFold | Fold | Modify | ∅ | ∅ | +| **Prism** | Prism | Optional | Prism | Optional | Traversal | AffineFold | AffineFold | Fold | Modify | Review | Unfold | +| **Optional** | Optional | Optional | Optional | Optional | Traversal | AffineFold | AffineFold | Fold | Modify | ∅ | ∅ | +| **Traversal** | Traversal | Traversal | Traversal | Traversal | Traversal | Fold | Fold | Fold | Modify | ∅ | ∅ | +| **Getter** | Getter | Getter | AffineFold | AffineFold | Fold | Getter | AffineFold | Fold | ∅ | ∅ | ∅ | +| **AffineFold**| AffineFold | AffineFold | AffineFold | AffineFold | Fold | AffineFold | AffineFold | Fold | ∅ | ∅ | ∅ | +| **Fold** | Fold | Fold | Fold | Fold | Fold | Fold | Fold | Fold | ∅ | ∅ | ∅ | +| **Modify** | Modify | Modify | Modify | Modify | Modify | ∅ | ∅ | ∅ | Modify | ∅ | ∅ | +| **Review** | Review | ∅ | Review | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | Review | Unfold | +| **Unfold** | Unfold | ∅ | Unfold | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | Unfold | Unfold | + +The structure of the voids is the taxonomy speaking: + +- The **Modify column/row corner**: a Modify exposes no focus to read + and no value to build with, so only write-capable pairs survive. +- The **Review / Unfold columns** are void for every outer that cannot + build totally (Lens, Optional, Traversal — their write-back needs a + leftover the build-only inner never produces; Getter / AffineFold / + Fold — their back-focus is honestly `Unit`, so there is nothing to + build *with*). Only the reversible outers (Iso, Prism, Review, + Unfold) reach them. +- The **Review / Unfold rows** mirror the columns: a build-only outer + exposes no readable focus, so only build halves (Iso / Prism via + `ReverseAccessor`, Review, Unfold) compose in. + +Four mechanisms produce the inhabited cells, all resolved at compile +time: same-carrier `AssociativeFunctor`, cross-carrier +`Morph`/`Composer` bridges, the `ReadCompose` read-collapse (any pair +with a read-only side), and the `ReverseAccessor`-gated build-collapse +(the Review / Unfold cells). See +[Concepts → Composition lattice](concepts.md#composition-lattice) for +the carrier-level bridge graph. ```scala mdoc:silent import dev.constructive.eo.optics.{Lens, Optic} @@ -115,7 +175,7 @@ ageL.modify(_ + 1)(alice) ``` Composes via `.andThen` with other Lenses and — transparently, -with no extra syntax — with `Optional` / `Setter` / `Traversal` +with no extra syntax — with `Optional` / `Modify` / `Traversal` optics too. The cross-carrier variant of `.andThen` summons a `Composer[F, G]` or `Composer[G, F]` to bring both sides under a common carrier. @@ -191,21 +251,27 @@ on your end. **Read-only / write-only collapse.** Composing *any* optic with a read-only `Getter` projects it to its **read-only counterpart**: the `Getter`'s `Unit` back-focus can't thread through a writable -`B`, so the write side is forgotten (`T = B = Unit`). A `ReadOnly[F]` -carrier projection picks the result — a total reader (`Lens` / `Iso`) -yields a `Getter`, a partial one (`Optional` / `Prism`) an -`AffineFold`: +`B`, so the write side is forgotten (`T = B = Unit`). The +`ReadCompose[F, G]` join picks the result from the two read +strengths — total ∘ total yields a `Getter`, any partial side an +`AffineFold`, any many-focus side a `Fold`: ```scala -lens.andThen(getter) // Getter -optional.andThen(getter) // AffineFold (partial read) -prism.andThen(getter) // AffineFold +lens.andThen(getter) // Getter +optional.andThen(getter) // AffineFold (partial read) +prism.andThen(getter) // AffineFold +traversal.andThen(getter) // Fold (many reads) ``` -Dually, composing with a write-only `Setter` collapses the *read* -side and yields a `Setter` (`lens.andThen(setter)`, -`optional.andThen(setter)`, …) — it modifies the focus through the -inner setter. One rule per side, across the whole algebra, rather +The same join fires with the read-only optic on the *outside* +(`getter.andThen(lens)` → Getter, `fold.andThen(prism)` → Fold), so +a chain that touches a read-only optic anywhere collapses to the +read-only rung at the join of all its read strengths. + +Dually, composing with a write-only `Modify` collapses the *read* +side and yields a `Modify` (`lens.andThen(modify)`, +`optional.andThen(modify)`, …) — it modifies the focus through the +inner modifier. One rule per side, across the whole algebra, rather than a per-family special case. ### AffineFold (read-only) @@ -247,7 +313,7 @@ sweep sizes 4 / 32 / 256 / 1024). / `everywhere` — rides this same `MultiFocus[PSVec]` carrier via `Traversal.selfChildren`; it's a typeclass over the carrier, not a new family node. See [Generics → `plate[S]`](generics.md), the -[Cookbook](cookbook.md), and the [Setter section](#setter) for +[Cookbook](cookbook.md), and the [Modify section](#modify) for `everywhere`. ```scala mdoc:silent @@ -333,7 +399,7 @@ nameLen.get(Person("Alice", 30)) ``` Getter → Getter composes via the ordinary `.andThen` (the fused -`DirectGetter.andThen`): `g1.andThen(g2).get(s)` reads +`Getter.andThen`): `g1.andThen(g2).get(s)` reads `g2.get(g1.get(s))`. ```scala mdoc @@ -341,42 +407,42 @@ val initial = Getter[Person, String](_.name).andThen(Getter[String, Char](_.head initial.get(Person("Alice", 30)) ``` -### Setter +### Modify -A `Setter[S, A]` can modify but not read — a write-only focus +A `Modify[S, A]` can modify but not read — a write-only focus for cases where the focus value isn't observable to the caller. -Carrier: `SetterF`. +Carrier: `ModifyF`. ```scala mdoc:silent -import dev.constructive.eo.optics.Setter +import dev.constructive.eo.optics.Modify -case class SetterConfig(values: Map[String, Int]) -val bumpAll = Setter[SetterConfig, SetterConfig, Int, Int] { f => cfg => +case class ModifyConfig(values: Map[String, Int]) +val bumpAll = Modify[ModifyConfig, ModifyConfig, Int, Int] { f => cfg => cfg.copy(values = cfg.values.view.mapValues(f).toMap) } ``` ```scala mdoc -bumpAll.modify(_ + 1)(SetterConfig(Map("a" -> 1, "b" -> 2))) +bumpAll.modify(_ + 1)(ModifyConfig(Map("a" -> 1, "b" -> 2))) ``` -Both `lens.andThen(setter)` (a Lens to a focus, then a Setter that -writes into it) and `setter.andThen(setter)` work — `SetterF` ships an -`AssociativeFunctor[SetterF, Xo, Xi]` instance, so the standard +Both `lens.andThen(modify)` (a Lens to a focus, then a Modify that +writes into it) and `modify.andThen(modify)` work — `ModifyF` ships an +`AssociativeFunctor[ModifyF, Xo, Xi]` instance, so the standard `Optic.andThen` resolution picks it up transparently. ```scala mdoc:silent import dev.constructive.eo.compose.Composer -import dev.constructive.eo.data.SetterF -import dev.constructive.eo.data.SetterF.given +import dev.constructive.eo.data.ModifyF +import dev.constructive.eo.data.ModifyF.given final case class Box(value: Int) final case class Holder(box: Box, tag: String) -val outer = summon[Composer[Tuple2, SetterF]].to( +val outer = summon[Composer[Tuple2, ModifyF]].to( Lens[Holder, Box](_.box, (s, b) => s.copy(box = b)) ) -val inner = summon[Composer[Tuple2, SetterF]].to( +val inner = summon[Composer[Tuple2, ModifyF]].to( Lens[Box, Int](_.value, (s, v) => s.copy(value = v)) ) val composed = outer.andThen(inner) @@ -386,16 +452,16 @@ val composed = outer.andThen(inner) composed.modify(_ + 1)(Holder(Box(10), "tag")) ``` -Setter is a write-side terminal: there is no `Composer[SetterF, _]` -outbound, so to *escape* a SetterF chain into a Forget / MultiFocus / -Lens you have to restructure with the Setter on the inside. +Modify is a write-side terminal: there is no `Composer[ModifyF, _]` +outbound, so to *escape* a ModifyF chain into a Forget / MultiFocus / +Lens you have to restructure with the Modify on the inside. -#### `everywhere` — a Setter that reaches every depth +#### `everywhere` — a Modify that reaches every depth -`Plated.everywhere[S]` is a `Setter` over a recursive type whose +`Plated.everywhere[S]` is a `Modify` over a recursive type whose `.modify` is the bottom-up recursive `transform` (see [Generics → `plate[S]`](generics.md)). -Because it's an ordinary Setter, the same `.andThen` you'd use to reach +Because it's an ordinary Modify, the same `.andThen` you'd use to reach *one* focus now applies that focus at **every** node of the tree — the "specify once, run everywhere" payoff. Give the type a `Plated` (by hand here; `plate[S]` from eo-generics derives it): @@ -418,8 +484,8 @@ given Plated[Tree] = Plated.fromChildren( }, ) -// A Setter that writes the Int in a Leaf; everywhere lifts it to all depths. -val leafN = Setter[Tree, Tree, Int, Int] { f => +// A Modify that writes the Int in a Leaf; everywhere lifts it to all depths. +val leafN = Modify[Tree, Tree, Int, Int] { f => { case Tree.Leaf(n) => Tree.Leaf(f(n)) case other => other @@ -434,7 +500,7 @@ everyLeaf.modify(_ + 1)(Tree.Branch(Tree.Leaf(1), Tree.Branch(Tree.Leaf(2), Tree ``` `everywhere` composes outward with any inner optic that bridges into -`SetterF` (Lens / Prism / Optional / Setter), and the `.modify` runs +`ModifyF` (Lens / Prism / Optional / Modify), and the `.modify` runs bottom-up, stack-safe to any depth. For the read side (every sub-term as a list) use `Plated.universe`; for the full worked Prism-composition recipe see the [Cookbook](cookbook.md), and for the macro that derives @@ -472,19 +538,113 @@ someLen.reverseGet("hello") There are no `fromIso` / `fromPrism` factories: an `Iso` or `Prism` already carries its build direction, so wrap it directly — `Review(iso.reverseGet)` -or `Review(prism.mend)`. eo has no `Prism.fromIso` (and the like) for the same -reason — a cross-optic conversion that merely re-exposes a sub-direction the -source already has would be redundant. (A general, non-bijective `Lens` can't -reconstruct its source from the focus alone, so there is deliberately no -`Lens`→`Review` path; build a `Review` with your own `A => S`.) +or `Review(prism.mend)` — or just compose: `iso.andThen(review)` and +`prism.andThen(review)` land a `Review` via the build-collapse. eo has no +`Prism.fromIso` (and the like) for the same reason — a cross-optic conversion +that merely re-exposes a sub-direction the source already has would be +redundant. (A general, non-bijective `Lens` can't reconstruct its source from +the focus alone, so there is deliberately no `Lens`→`Review` path; build a +`Review` with your own `A => S`.) + +Review builds one `S` from one focus. For the many-focus build — assemble +one whole from an `F`-layer of parts — see [Unfold](#unfold), Review's +mirror on the many rung. + +### Unfold + +An `Unfold[T, B, F]` is the build-only **many** optic — the mirror of +[`Fold`](#fold) exactly as `Review` mirrors `Getter`. It wraps the one +real map + +``` +embed : F[B] => T // many → one +``` + +"assemble one `T` from a layer of parts `F[B]`" — the **algebra** of a +recursion scheme, and the aggregation arrow ("build an order total from +its line items"). Carrier: `Forget[F]` with `S = A = Unit`, the same +carrier as `Fold` with the opposite side vestigial. + +```scala mdoc:silent +import dev.constructive.eo.optics.Unfold + +val total = Unfold((xs: List[BigDecimal]) => xs.sum) +``` + +```scala mdoc +total.embed(List(BigDecimal(9.99), BigDecimal(5.00))) +``` + +Two constructors, because the vestigial read side is not free the way +`Getter`'s vestigial write side is (`Unit` discards for free; producing +an `F[Unit]` needs `pure`): + +- `Unfold.apply` — for `Applicative` carriers (`List`, `Option`, + `Vector`, …). The vestigial `to` is honestly `pure(())`. +- `Unfold.algebra` — constraint-free, for **pattern functors**, which + admit `Functor` / `Traverse` but no `Applicative` (`pure` cannot pick + a constructor). Only the build surface is available; forcing a + read-side operation fails loudly. + +```scala mdoc:silent +import cats.Functor + +enum ExprF[+A]: + case NumF(n: Int) + case AddF(l: A, r: A) + +given Functor[ExprF] with + def map[A, B](fa: ExprF[A])(f: A => B): ExprF[B] = fa match + case ExprF.NumF(n) => ExprF.NumF(n) + case ExprF.AddF(l, r) => ExprF.AddF(f(l), f(r)) + +// the evaluation algebra of a recursion scheme, carried as an optic +val evalAlg = Unfold.algebra[Int, Int, ExprF] { + case ExprF.NumF(n) => n + case ExprF.AddF(l, r) => l + r +} +``` + +```scala mdoc +evalAlg.embed(ExprF.AddF(2, 3)) +``` + +Unfold composes along the build rung in both directions — and because +the seams thread plain values (never an `F`-layer), pattern-functor +algebras compose with no extra constraints: + +```scala mdoc:silent +import dev.constructive.eo.optics.Review + +// post-process the assembled whole: Review ∘ Unfold = Unfold +val render = Review[String, Int](n => s"= $n").andThen(evalAlg) + +// pre-process each part: Unfold ∘ Review = Unfold (Functor[F] only) +val fromStrings = evalAlg.andThen(Review[Int, String](_.toInt)) +``` + +```scala mdoc +render.embed(ExprF.AddF(20, 22)) +fromStrings.embed(ExprF.AddF("2", "3")) +``` + +Iso and Prism build halves also compose in (`iso.andThen(unfold)`, +`unfold.andThen(prism)` — the prism *mends* each part); see the +[composition matrix](#composition-matrix) for the full row and column. +An algebra assembled this way drops straight into the recursion-scheme +fold engine — `Schemes.cata` accepts a pure `Unfold[A, A, PSVec]` +algebra; see [Recursion schemes](schemes.md). ### AffineFold An `AffineFold[S, A]` is the read-only 0-or-1 focus shape: a partial projection with no write-back path. Type alias for -`Optic[S, Unit, A, A, Affine]` — the `T = Unit` slot statically -rules out `.modify` / `.replace`, so the only operations are -`.getOption`, `.foldMap`, and `.modifyA` (effectful read). +`Optic[S, Unit, A, Unit, Affine]` — both `T` and the back-focus +`B` are pinned to `Unit`, which statically rules out `.modify` / +`.replace` and leaves `.getOption`, `.foldMap`, and `.modifyA` +(effectful read) as the surface. (Constructors return the concrete +`PickFold` subclass, whose fused `andThen` keeps composed read-only +chains concrete.) Use this when the source has no natural write-back (`headOption` on a List, predicate-gated filters), or as an @@ -523,11 +683,12 @@ bespoke `fromOptional` / `fromPrism` factory: the conversion is a one-liner, and eo provides no `Getter.fromLens` / `Fold.fromTraversal` for the same reason.) -**Composition note.** Direct `lens.andThen(af)` on an -`AffineFold` does not type-check: the outer `B` slot doesn't -align with the inner `T = Unit`. Build a full composed -`Optional` through the Lens chain and narrow the result with -`AffineFold(optional.getOption)`. +**Composition note.** `lens.andThen(affineFold)` composes +directly — the read-only-inner `andThen` overload routes it +through `ReadCompose`, landing an `AffineFold` (see the +[composition matrix](#composition-matrix)). The same holds with +the AffineFold on the outside: `affineFold.andThen(lens)` → +AffineFold. ### Fold @@ -557,6 +718,10 @@ positive.foldMap(identity[Int])(3) positive.foldMap(identity[Int])(-3) ``` +`Fold` tears an `F`-layer down to a summary; its exact dual — +assembling a `T` *from* an `F`-layer — is [Unfold](#unfold), which +rides the same `Forget[F]` carrier with the opposite side vestigial. + ## Composition limits A few categories of pair are either intentionally **not** bridged or @@ -571,51 +736,45 @@ directly. If your outer *does* focus on an `F[A]` (e.g. `fromPrismF` / `fromOptionalF` factories to lift into `MultiFocus[F]` and chain there. -**`Traversal.each` × `Fold[F]` / `MultiFocus[F]`** — `MultiFocus[PSVec]` +**`Traversal.each` × `MultiFocus[G]` read-write** — `MultiFocus[PSVec]` (the `Traversal.each` carrier) cannot widen into another `MultiFocus[G]`'s -per-candidate cardinality model without a synthetic count. The -idiomatic workaround pushes the inner under the traversal: -`traversal.modify(a => inner.replace(b)(a))(s)` for a `MultiFocus` -inner; `traversal.foldMap(f)(s)` (the read-only escape on any -`MultiFocus[F]`-carrier optic) when you only need the fold side. - -**Cross-F `Fold[F].andThen(Fold[G])`** — `Composer[Forget[F], Forget[G]]` -doesn't ship (Composer's signature has no slot for a per-call natural -transformation, and the carrier-generic `Optic.andThen` requires the -same `F`). Instead, `Forget.scala` ships a Forget-specific `.andThen` -extension that takes a user-supplied `cats.~>[F, G]` plus -`FlatMap[G]` and produces a `Forget[G]`-carrier optic: - -```scala -import cats.~> -val outer: Optic[Source, Unit, A, A, Forget[List]] = ... -val inner: Optic[A, Unit, B, B, Forget[Option]] = ... -given listHead: List ~> Option = new (List ~> Option): - def apply[T](xs: List[T]): Option[T] = xs.headOption -val composed: Optic[Source, Unit, B, B, Forget[Option]] = - outer.andThen(inner) -``` - -The user picks the meaning by choosing the nat (e.g. `List ~> Option` -via `headOption`, `Option ~> List` via `toList`, `List ~> LazyList` -for streaming). Result carrier is `Forget[G]` — downstream composition -continues in `G`'s typeclass landscape. Restricted to `T = Unit` -(the Fold case) since cross-F composition has no natural way to -thread `from` for general `T`. - -**`SetterF` outbound** — Setter is a write-side terminal: there is no -outbound `Composer[SetterF, _]`, so a chain that reaches Setter cannot +per-candidate cardinality model without a synthetic count, so the full +read-*write* pairing across different multi-focus carriers doesn't +bridge. The **read side is not limited**: `traversal.andThen(fold)` and +`traversal.andThen(otherReadOnly)` collapse via `ReadCompose` to a +`Fold` (see the [composition matrix](#composition-matrix)). For a +read-write inner, push it under the traversal instead: +`traversal.modify(a => inner.replace(b)(a))(s)`. + +**Cross-F `Fold[F].andThen(Fold[G])`** — there is no +`Composer[Forget[F], Forget[G]]` (Composer's signature has no slot for +a per-call natural transformation). The read-collapse covers the +composition anyway: any fold ∘ fold pair lands a List-backed `Fold` +via `ReadCompose`'s many-fold join, and the *same-F* pair takes the +fused `ForgetFold.andThen` fast path (`read(s).flatMap(inner.read)`, +requires `FlatMap[F]`). If you need the result in a specific `G` +(e.g. streaming via `LazyList`), apply your own `F ~> G` to the fold's +output rather than composing carriers. + +**`ModifyF` outbound** — Modify is a write-side terminal: there is no +outbound `Composer[ModifyF, _]`, so a chain that reaches Modify cannot widen back into a Forget / MultiFocus / Lens. Same-carrier -`setter.andThen(setter)` *does* work — `SetterF.assocSetterF` ships -`AssociativeFunctor[SetterF, Xo, Xi]` with `Z = (Fst[Xo], Snd[Xi])`, +`modify.andThen(modify)` *does* work — `ModifyF.assocModifyF` ships +`AssociativeFunctor[ModifyF, Xo, Xi]` with `Z = (Fst[Xo], Snd[Xi])`, so the standard `Optic.andThen` resolves transparently. **Fixed-arity traversal (`Traversal.two` / `.three` / `.four`)** — these factories produce `MultiFocus[Function1[Int, *]]`-carrier optics, so they inherit the Grate sub-shape's composability: `Iso ↪ -MF[Function1[Int, *]]`, `MF[Function1[Int, *]] ↪ SetterF`, and +MF[Function1[Int, *]]`, `MF[Function1[Int, *]] ↪ ModifyF`, and same-carrier `.andThen` via `mfAssocFunction1`. Lens / Prism / Optional do NOT bridge in (Function1 lacks `Foldable` / `Alternative`). -The full taxonomy with cell-by-cell rationale lives in -[`docs/research/2026-04-23-composition-gap-analysis.md`](https://github.com/Constructive-Programming/eo/blob/main/docs/research/2026-04-23-composition-gap-analysis.md). +The authoritative cell-by-cell record is +[`CompositionMatrixSpec`](https://github.com/Constructive-Programming/eo/blob/main/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala) +— every inhabited and void cell of the +[composition matrix](#composition-matrix) above is asserted there via +`compiletime.testing.typeChecks`, so a regression in any cell turns the +build red. (The historical derivation lives in +[`docs/research/2026-04-23-composition-gap-analysis.md`](https://github.com/Constructive-Programming/eo/blob/main/docs/research/2026-04-23-composition-gap-analysis.md), +which predates the `ReadCompose` collapse and the `Unfold` family.) diff --git a/site/docs/schemes.md b/site/docs/schemes.md index 7f92c4dc..fa3e73de 100644 --- a/site/docs/schemes.md +++ b/site/docs/schemes.md @@ -64,6 +64,36 @@ val bodySum: Getter[Payload, Int] = 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` A coalgebra maps a seed to its child seeds plus a builder for the node (the canonical anamorphism diff --git a/site/laika-static/static/optic-taxonomy-3d.svg b/site/laika-static/static/optic-taxonomy-3d.svg new file mode 100644 index 00000000..0c660fd2 --- /dev/null +++ b/site/laika-static/static/optic-taxonomy-3d.svg @@ -0,0 +1,94 @@ + + + + + +Getter + +AffineFold +B = Unit + +Fold + +Iso + +Lens + +Prism + +Optional +Affine carrier + +Traversal +fixed/Grate: total · each: contextual + +fallible write +planned + +BiAffine +planned + +fallible each +planned + +Review +Iso's and Prism's build half — mend is total + +Unfold + +Modify +the contextual write half — focus-agnostic + +fallible build +planned + +focus nature (ReadCompose): total → fallible → multiple + +source nature: total → contextual → fallible (WriteCompose — planned) + +capability +read-only (top) +write-only (bottom) +read-only +B = Unit: from is vacuous — each family spans the source axis +read-write +write / build only +the from halves of the layer above + diff --git a/site/tools/gen-taxonomy-svg.py b/site/tools/gen-taxonomy-svg.py new file mode 100644 index 00000000..dbefe682 --- /dev/null +++ b/site/tools/gen-taxonomy-svg.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Generate the isometric 3-axis optic-family taxonomy SVG for site/docs/optics.md. + +Axes: + u (lower-right) : FOCUS nature — total, fallible, multiple (the ReadCompose join lattice) + v (upper-right) : SOURCE nature — total, contextual, fallible (fallible = WriteCompose / + BiAffine plan) + vertical (layers): capability — read-only (top), read-write (middle), + write/build-only (bottom) + +All three layers are full planes. Read-only families pin B = Unit (terminal, +not Nothing), so their vestigial `from` is vacuously satisfied at every source +nature — each spans the source axis as a bar (the dual of Modify spanning the +focus axis). Write-only families keep a real `from`, so each sits directly +below the read-write cell(s) whose build half it is. The middle layer is the +FULL focus x source grid — every cell is shipped or scoped to the +failure-typed-build (BiAffine) plan; no accidental holes. +""" +import math, os + +S = 112 # tile edge length driver +UX, UY = 0.866 * S, 0.5 * S # u: read-cardinality direction (lower-right) +VX, VY = 0.866 * S, -0.5 * S # v: write-cardinality direction (upper-right) +LD = 300 # vertical gap between layer origins +MX, MY = 190, 180 # margins (left margin holds the capability-axis label) + +TOP, MID, BOT = 0, LD, 2 * LD + +def pt(i, j, dy=0.0): + return (MX + i * UX + j * VX, MY + i * UY + j * VY + dy) + +def tile(i, j, dy, cls, dash=False, uspan=1, vspan=1): + p0, p1 = pt(i, j, dy), pt(i + uspan, j, dy) + p2, p3 = pt(i + uspan, j + vspan, dy), pt(i, j + vspan, dy) + d = ' '.join(f"{x:.1f},{y:.1f}" for x, y in (p0, p1, p2, p3)) + dash_attr = ' stroke-dasharray="6 5"' if dash else '' + return f'' + +def center(i, j, dy, uspan=1, vspan=1): + x0, y0 = pt(i, j, dy) + x1, y1 = pt(i + uspan, j + vspan, dy) + return ((x0 + x1) / 2, (y0 + y1) / 2) + +def label(i, j, dy, name, sub=None, cls="fam", uspan=1, vspan=1): + cx, cy = center(i, j, dy, uspan, vspan) + if sub: + return (f'{name}\n' + f'{sub}') + return f'{name}' + +parts = [] + +parts.append('''''') + +# ---------- vertical guides through the three layers (behind everything) ---------- +for (i, j) in [(0, 0), (3, 3)]: + x0, y0 = pt(i, j, TOP) + x1, y1 = pt(i, j, BOT) + parts.append(f'') + +# ---------- TOP layer: read-only — bars spanning the source axis ---------- +# B = Unit (terminal, not Nothing): the vestigial `from` is vacuously satisfied at +# every source nature, so each read-only family holds the whole source axis — the +# dual of Modify spanning the focus axis below. +for i, name in enumerate(['Getter', 'AffineFold', 'Fold']): + parts.append(tile(i, 0, TOP, 'ro', vspan=3)) + parts.append(label(i, 0, TOP, name, 'B = Unit' if i == 1 else None, vspan=3)) + +# ---------- MIDDLE layer: read-write (full grid: u = focus nature, v = source nature) ---------- +# u index: 0 → total, 1 → fallible, 2 → multiple +# v index: 0 → total, 1 → contextual, 2 → fallible +parts.append(tile(0, 0, MID, 'rw')) +parts.append(label(0, 0, MID, 'Iso', None)) +parts.append(tile(0, 1, MID, 'rw')) +parts.append(label(0, 1, MID, 'Lens', None)) +parts.append(tile(1, 0, MID, 'rw')) +parts.append(label(1, 0, MID, 'Prism', None)) +parts.append(tile(1, 1, MID, 'rw')) +parts.append(label(1, 1, MID, 'Optional', 'Affine carrier')) +parts.append(tile(2, 0, MID, 'rw', vspan=2)) +parts.append(label(2, 0, MID, 'Traversal', 'fixed/Grate: total · each: contextual', vspan=2)) +# fallible-source row — the failure-typed-build (BiAffine) plan +parts.append(tile(0, 2, MID, 'plan', dash=True)) +parts.append(label(0, 2, MID, 'fallible write', 'planned', cls='plan-t')) +parts.append(tile(1, 2, MID, 'plan', dash=True)) +parts.append(label(1, 2, MID, 'BiAffine', 'planned', cls='plan-t')) +parts.append(tile(2, 2, MID, 'plan', dash=True)) +parts.append(label(2, 2, MID, 'fallible each', 'planned', cls='plan-t')) + +# ---------- BOTTOM layer: write/build-only — a FULL plane (from is real, to is vestigial) ---------- +# Each cell sits directly below the read-write cell(s) whose build half it is. +parts.append(tile(0, 0, BOT, 'bo', uspan=2)) +parts.append(label(0, 0, BOT, 'Review', "Iso's and Prism's build half — mend is total", uspan=2)) +parts.append(tile(2, 0, BOT, 'bo')) +parts.append(label(2, 0, BOT, 'Unfold', None)) +parts.append(tile(0, 1, BOT, 'mod', uspan=3)) +parts.append(label(0, 1, BOT, 'Modify', 'the contextual write half — focus-agnostic', uspan=3)) +parts.append(tile(0, 2, BOT, 'plan', dash=True, uspan=3)) +parts.append(label(0, 2, BOT, 'fallible build', 'planned', cls='plan-t', uspan=3)) + +# ---------- axis arrows + labels ---------- +def arrow(x0, y0, x1, y1): + ang = math.atan2(y1 - y0, x1 - x0) + l = 9 + a1 = (x1 - l * math.cos(ang - 0.42), y1 - l * math.sin(ang - 0.42)) + a2 = (x1 - l * math.cos(ang + 0.42), y1 - l * math.sin(ang + 0.42)) + return (f'' + f'') + +# read-cardinality axis (u), under the middle grid's front edge +a0, a1 = pt(0, -0.4, MID), pt(3.05, -0.4, MID) +parts.append(arrow(*a0, *a1)) +am = ((a0[0] + a1[0]) / 2, (a0[1] + a1[1]) / 2) +parts.append(f'focus nature (ReadCompose): total → fallible → multiple') + +# write-cardinality axis (v), upper-left of the middle grid +b0, b1 = pt(-0.4, 0, MID), pt(-0.4, 3.05, MID) +parts.append(arrow(*b0, *b1)) +bm = ((b0[0] + b1[0]) / 2, (b0[1] + b1[1]) / 2) +parts.append(f'source nature: total → contextual → fallible (WriteCompose — planned)') + +# capability axis: vertical, far left +rx = MX - 120 +ry0, ry1 = MY + 40, MY + BOT + 20 +parts.append(arrow(rx, ry0, rx, ry1)) +parts.append(f'capability') +parts.append(f'read-only (top)') +parts.append(f'write-only (bottom)') + +# layer captions on the right +for dy, name, sub in [(TOP, 'read-only', 'B = Unit: from is vacuous — each family spans the source axis'), + (MID, 'read-write', None), + (BOT, 'write / build only', 'the from halves of the layer above')]: + cx, cy = pt(3.3, 1.6, dy) + parts.append(f'{name}') + if sub: + parts.append(f'{sub}') + +W = int(MX + 3 * UX + 3 * VX + 235) +H = int(MY + 3 * UY + BOT + 105) +svg = (f'\n' + '\n'.join(parts) + '\n\n') + +out = os.path.join(os.path.dirname(__file__), '..', 'laika-static', 'static', 'optic-taxonomy-3d.svg') +open(out, 'w').write(svg) +print(f"wrote {os.path.normpath(out)} viewBox 0 0 {W} {H}") diff --git a/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala b/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala index 01e1a92e..c14833c0 100644 --- a/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala @@ -20,7 +20,7 @@ import scala.compiletime.testing.typeChecks import org.specs2.mutable.Specification import optics.* -import data.{Affine, Direct, Forget, MultiFocus, PSVec, SetterF} +import data.{Affine, Direct, Forget, MultiFocus, PSVec, ModifyF} object MatrixFixtures: case class Box[A](a: A) @@ -49,10 +49,10 @@ object MatrixFixtures: val o_affoldL = AffineFold[Box[List[Int]], List[Int]](b => Some(b.a)) val o_fold = Fold[List, Box[Int]] val o_foldL = Fold[List, List[Int]] - val o_setter = Setter[Box[Box[Int]], Box[Box[Int]], Box[Int], Box[Int]](f => b => Box(f(b.a))) + val o_modify = Modify[Box[Box[Int]], Box[Box[Int]], Box[Int], Box[Int]](f => b => Box(f(b.a))) - val o_setterL = - Setter[Box[List[Int]], Box[List[Int]], List[Int], List[Int]](f => b => Box(f(b.a))) + val o_modifyL = + Modify[Box[List[Int]], Box[List[Int]], List[Int], List[Int]](f => b => Box(f(b.a))) val o_review = Review[Box[Box[Int]], Box[Int]](Box(_)) val o_reviewL = Review[Box[List[Int]], List[Int]](Box(_)) @@ -64,8 +64,10 @@ object MatrixFixtures: val i_getter = Getter[Box[Int], Int](_.a) val i_affold = AffineFold[Box[Int], Int](b => Some(b.a)) val i_fold = Fold[List, Int] - val i_setter = Setter[Box[Int], Box[Int], Int, Int](f => b => Box(f(b.a))) + val i_modify = Modify[Box[Int], Box[Int], Int, Int](f => b => Box(f(b.a))) val i_review = Review[Box[Int], Int](Box(_)) + val o_unfold = Unfold((xs: List[Box[Int]]) => Box(Box(xs.map(_.a).sum))) + val i_unfold = Unfold((xs: List[Int]) => Box(xs.sum)) class CompositionMatrixSpec extends Specification: import MatrixFixtures.* @@ -115,16 +117,20 @@ class CompositionMatrixSpec extends Specification: "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_isoL.andThen(i_fold)" ) must beTrue } - "iso ∘ setter → Optic" >> { - typeChecks("o_iso.andThen(i_setter)") must beTrue // resolves with no expected type + "iso ∘ modify → Optic" >> { + typeChecks("o_iso.andThen(i_modify)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_iso.andThen(i_setter)" + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, ModifyF] = o_iso.andThen(i_modify)" ) must beTrue } "iso ∘ review → Review" >> { typeChecks("o_iso.andThen(i_review)") must beTrue // resolves with no expected type typeChecks("val r: Review[Box[Box[Int]], Int] = o_iso.andThen(i_review)") must beTrue } + "iso ∘ unfold → Unfold" >> { + typeChecks("o_iso.andThen(i_unfold)") must beTrue // resolves with no expected type + typeChecks("val r: Unfold[Box[Box[Int]], Int, List] = o_iso.andThen(i_unfold)") must beTrue + } } "lens (outer) composition row" >> { @@ -172,15 +178,18 @@ class CompositionMatrixSpec extends Specification: "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_lensL.andThen(i_fold)" ) must beTrue } - "lens ∘ setter → Optic" >> { - typeChecks("o_lens.andThen(i_setter)") must beTrue // resolves with no expected type + "lens ∘ modify → Optic" >> { + typeChecks("o_lens.andThen(i_modify)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_lens.andThen(i_setter)" + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, ModifyF] = o_lens.andThen(i_modify)" ) must beTrue } "lens ∘ review must not compile" >> { typeChecks("o_lens.andThen(i_review)") must beFalse } + "lens ∘ unfold must not compile" >> { + typeChecks("o_lens.andThen(i_unfold)") must beFalse + } } "prism (outer) composition row" >> { @@ -228,16 +237,20 @@ class CompositionMatrixSpec extends Specification: "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_prismL.andThen(i_fold)" ) must beTrue } - "prism ∘ setter → Optic" >> { - typeChecks("o_prism.andThen(i_setter)") must beTrue // resolves with no expected type + "prism ∘ modify → Optic" >> { + typeChecks("o_prism.andThen(i_modify)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_prism.andThen(i_setter)" + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, ModifyF] = o_prism.andThen(i_modify)" ) must beTrue } "prism ∘ review → Review" >> { typeChecks("o_prism.andThen(i_review)") must beTrue // resolves with no expected type typeChecks("val r: Review[Box[Box[Int]], Int] = o_prism.andThen(i_review)") must beTrue } + "prism ∘ unfold → Unfold" >> { + typeChecks("o_prism.andThen(i_unfold)") must beTrue // resolves with no expected type + typeChecks("val r: Unfold[Box[Box[Int]], Int, List] = o_prism.andThen(i_unfold)") must beTrue + } } "optional (outer) composition row" >> { @@ -285,15 +298,18 @@ class CompositionMatrixSpec extends Specification: "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_optionalL.andThen(i_fold)" ) must beTrue } - "optional ∘ setter → Optic" >> { - typeChecks("o_optional.andThen(i_setter)") must beTrue // resolves with no expected type + "optional ∘ modify → Optic" >> { + typeChecks("o_optional.andThen(i_modify)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_optional.andThen(i_setter)" + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, ModifyF] = o_optional.andThen(i_modify)" ) must beTrue } "optional ∘ review must not compile" >> { typeChecks("o_optional.andThen(i_review)") must beFalse } + "optional ∘ unfold must not compile" >> { + typeChecks("o_optional.andThen(i_unfold)") must beFalse + } } "trav (outer) composition row" >> { @@ -345,15 +361,18 @@ class CompositionMatrixSpec extends Specification: "val r: Optic[List[List[Int]], Unit, Int, Unit, Forget[List]] = o_travL.andThen(i_fold)" ) must beTrue } - "trav ∘ setter → Optic" >> { - typeChecks("o_trav.andThen(i_setter)") must beTrue // resolves with no expected type + "trav ∘ modify → Optic" >> { + typeChecks("o_trav.andThen(i_modify)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[List[Box[Int]], List[Box[Int]], Int, Int, SetterF] = o_trav.andThen(i_setter)" + "val r: Optic[List[Box[Int]], List[Box[Int]], Int, Int, ModifyF] = o_trav.andThen(i_modify)" ) must beTrue } "trav ∘ review must not compile" >> { typeChecks("o_trav.andThen(i_review)") must beFalse } + "trav ∘ unfold must not compile" >> { + typeChecks("o_trav.andThen(i_unfold)") must beFalse + } } "getter (outer) composition row" >> { @@ -393,12 +412,15 @@ class CompositionMatrixSpec extends Specification: "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_getterL.andThen(i_fold)" ) must beTrue } - "getter ∘ setter must not compile" >> { - typeChecks("o_getter.andThen(i_setter)") must beFalse + "getter ∘ modify must not compile" >> { + typeChecks("o_getter.andThen(i_modify)") must beFalse } "getter ∘ review must not compile" >> { typeChecks("o_getter.andThen(i_review)") must beFalse } + "getter ∘ unfold must not compile" >> { + typeChecks("o_getter.andThen(i_unfold)") must beFalse + } } "affold (outer) composition row" >> { @@ -438,12 +460,15 @@ class CompositionMatrixSpec extends Specification: "val r: Optic[Box[List[Int]], Unit, Int, Unit, Forget[List]] = o_affoldL.andThen(i_fold)" ) must beTrue } - "affold ∘ setter must not compile" >> { - typeChecks("o_affold.andThen(i_setter)") must beFalse + "affold ∘ modify must not compile" >> { + typeChecks("o_affold.andThen(i_modify)") must beFalse } "affold ∘ review must not compile" >> { typeChecks("o_affold.andThen(i_review)") must beFalse } + "affold ∘ unfold must not compile" >> { + typeChecks("o_affold.andThen(i_unfold)") must beFalse + } } "fold (outer) composition row" >> { @@ -495,62 +520,68 @@ class CompositionMatrixSpec extends Specification: "val r: Optic[List[List[Int]], Unit, Int, Unit, Forget[List]] = o_foldL.andThen(i_fold)" ) must beTrue } - "fold ∘ setter must not compile" >> { - typeChecks("o_fold.andThen(i_setter)") must beFalse + "fold ∘ modify must not compile" >> { + typeChecks("o_fold.andThen(i_modify)") must beFalse } "fold ∘ review must not compile" >> { typeChecks("o_fold.andThen(i_review)") must beFalse } + "fold ∘ unfold must not compile" >> { + typeChecks("o_fold.andThen(i_unfold)") must beFalse + } } - "setter (outer) composition row" >> { - "setter ∘ iso → Optic" >> { - typeChecks("o_setter.andThen(i_iso)") must beTrue // resolves with no expected type + "modify (outer) composition row" >> { + "modify ∘ iso → Optic" >> { + typeChecks("o_modify.andThen(i_iso)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_setter.andThen(i_iso)" + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, ModifyF] = o_modify.andThen(i_iso)" ) must beTrue } - "setter ∘ lens → Optic" >> { - typeChecks("o_setter.andThen(i_lens)") must beTrue // resolves with no expected type + "modify ∘ lens → Optic" >> { + typeChecks("o_modify.andThen(i_lens)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_setter.andThen(i_lens)" + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, ModifyF] = o_modify.andThen(i_lens)" ) must beTrue } - "setter ∘ prism → Optic" >> { - typeChecks("o_setter.andThen(i_prism)") must beTrue // resolves with no expected type + "modify ∘ prism → Optic" >> { + typeChecks("o_modify.andThen(i_prism)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_setter.andThen(i_prism)" + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, ModifyF] = o_modify.andThen(i_prism)" ) must beTrue } - "setter ∘ optional → Optic" >> { - typeChecks("o_setter.andThen(i_optional)") must beTrue // resolves with no expected type + "modify ∘ optional → Optic" >> { + typeChecks("o_modify.andThen(i_optional)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_setter.andThen(i_optional)" + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, ModifyF] = o_modify.andThen(i_optional)" ) must beTrue } - "setter ∘ trav → Optic" >> { - typeChecks("o_setterL.andThen(i_trav)") must beTrue // resolves with no expected type + "modify ∘ trav → Optic" >> { + typeChecks("o_modifyL.andThen(i_trav)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[Box[List[Int]], Box[List[Int]], Int, Int, SetterF] = o_setterL.andThen(i_trav)" + "val r: Optic[Box[List[Int]], Box[List[Int]], Int, Int, ModifyF] = o_modifyL.andThen(i_trav)" ) must beTrue } - "setter ∘ getter must not compile" >> { - typeChecks("o_setter.andThen(i_getter)") must beFalse + "modify ∘ getter must not compile" >> { + typeChecks("o_modify.andThen(i_getter)") must beFalse } - "setter ∘ affold must not compile" >> { - typeChecks("o_setter.andThen(i_affold)") must beFalse + "modify ∘ affold must not compile" >> { + typeChecks("o_modify.andThen(i_affold)") must beFalse } - "setter ∘ fold must not compile" >> { - typeChecks("o_setterL.andThen(i_fold)") must beFalse + "modify ∘ fold must not compile" >> { + typeChecks("o_modifyL.andThen(i_fold)") must beFalse } - "setter ∘ setter → Optic" >> { - typeChecks("o_setter.andThen(i_setter)") must beTrue // resolves with no expected type + "modify ∘ modify → Optic" >> { + typeChecks("o_modify.andThen(i_modify)") must beTrue // resolves with no expected type typeChecks( - "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, SetterF] = o_setter.andThen(i_setter)" + "val r: Optic[Box[Box[Int]], Box[Box[Int]], Int, Int, ModifyF] = o_modify.andThen(i_modify)" ) must beTrue } - "setter ∘ review must not compile" >> { - typeChecks("o_setter.andThen(i_review)") must beFalse + "modify ∘ review must not compile" >> { + typeChecks("o_modify.andThen(i_review)") must beFalse + } + "modify ∘ unfold must not compile" >> { + typeChecks("o_modify.andThen(i_unfold)") must beFalse } } @@ -581,11 +612,61 @@ class CompositionMatrixSpec extends Specification: "review ∘ fold must not compile" >> { typeChecks("o_reviewL.andThen(i_fold)") must beFalse } - "review ∘ setter must not compile" >> { - typeChecks("o_review.andThen(i_setter)") must beFalse + "review ∘ modify must not compile" >> { + typeChecks("o_review.andThen(i_modify)") must beFalse } "review ∘ review → Review" >> { typeChecks("o_review.andThen(i_review)") must beTrue // resolves with no expected type typeChecks("val r: Review[Box[Box[Int]], Int] = o_review.andThen(i_review)") must beTrue } + "review ∘ unfold → Unfold" >> { + typeChecks("o_review.andThen(i_unfold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Unfold[Box[Box[Int]], Int, List] = o_review.andThen(i_unfold)" + ) must beTrue + } + } + + "unfold (outer) composition row" >> { + "unfold ∘ iso → Unfold" >> { + typeChecks("o_unfold.andThen(i_iso)") must beTrue // resolves with no expected type + typeChecks("val r: Unfold[Box[Box[Int]], Int, List] = o_unfold.andThen(i_iso)") must beTrue + } + "unfold ∘ lens must not compile" >> { + typeChecks("o_unfold.andThen(i_lens)") must beFalse + } + "unfold ∘ prism → Unfold" >> { + typeChecks("o_unfold.andThen(i_prism)") must beTrue // resolves with no expected type + typeChecks("val r: Unfold[Box[Box[Int]], Int, List] = o_unfold.andThen(i_prism)") must beTrue + } + "unfold ∘ optional must not compile" >> { + typeChecks("o_unfold.andThen(i_optional)") must beFalse + } + "unfold ∘ trav must not compile" >> { + typeChecks("o_unfold.andThen(i_trav)") must beFalse + } + "unfold ∘ getter must not compile" >> { + typeChecks("o_unfold.andThen(i_getter)") must beFalse + } + "unfold ∘ affold must not compile" >> { + typeChecks("o_unfold.andThen(i_affold)") must beFalse + } + "unfold ∘ fold must not compile" >> { + typeChecks("o_unfold.andThen(i_fold)") must beFalse + } + "unfold ∘ modify must not compile" >> { + typeChecks("o_unfold.andThen(i_modify)") must beFalse + } + "unfold ∘ review → Unfold" >> { + typeChecks("o_unfold.andThen(i_review)") must beTrue // resolves with no expected type + typeChecks( + "val r: Unfold[Box[Box[Int]], Int, List] = o_unfold.andThen(i_review)" + ) must beTrue + } + "unfold ∘ unfold → Unfold" >> { + typeChecks("o_unfold.andThen(i_unfold)") must beTrue // resolves with no expected type + typeChecks( + "val r: Unfold[Box[Box[Int]], Int, List] = o_unfold.andThen(i_unfold)" + ) must beTrue + } } diff --git a/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala index b4e94060..251c269b 100644 --- a/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/EoSpecificLawsSpec.scala @@ -9,7 +9,7 @@ import org.specs2.mutable.Specification import optics.{Iso, Lens, Optic, Optional, Prism, Traversal} import optics.Optic.* -import data.{Affine, Direct, MultiFocus, PSVec, SetterF} +import data.{Affine, Direct, MultiFocus, PSVec, ModifyF} import laws.eo.{ IsoComposeLaws, LensComposeLaws, @@ -54,11 +54,11 @@ class EoSpecificLawsSpec extends Specification with CheckAllHelpers: // =============== A1/I1 — morph preserves modify ================== - // covers: Morph from Tuple2 → SetterF on a Lens - checkAllMorphPreservesModifyFor[(Int, String), Int, Tuple2, SetterF]( - "Lens.morph[SetterF] preserves modify (I1)", + // covers: Morph from Tuple2 → ModifyF on a Lens + checkAllMorphPreservesModifyFor[(Int, String), Int, Tuple2, ModifyF]( + "Lens.morph[ModifyF] preserves modify (I1)", firstLens, - firstLens.morph[SetterF], + firstLens.morph[ModifyF], ) // covers: Morph from Tuple2 → Affine on a Lens @@ -75,36 +75,36 @@ class EoSpecificLawsSpec extends Specification with CheckAllHelpers: doubleIso.morph[Tuple2], ) - // covers: Morph from MultiFocus[Function1[Int, *]] → SetterF on a tuple-shaped MultiFocus - // (the absorbed Grate). The `multifocus2setter` Composer widens any MultiFocus-carrier optic to - // the Setter API. The MorphLaws.A1 check pins down that the lifted SetterF's `.modify(f)(s)` + // covers: Morph from MultiFocus[Function1[Int, *]] → ModifyF on a tuple-shaped MultiFocus + // (the absorbed Grate). The `multifocus2modify` Composer widens any MultiFocus-carrier optic to + // the Modify API. The MorphLaws.A1 check pins down that the lifted ModifyF's `.modify(f)(s)` // produces the same `T` as the original MultiFocus's `.modify(f)(s)` — the whole structural // soundness of the bridge in one law. val tuple2MultiFocusFnForMorph : Optic[(Int, Int), (Int, Int), Int, Int, MultiFocus[Function1[Int, *]]] = MultiFocus.tuple[(Int, Int), Int] - checkAllMorphPreservesModifyFor[(Int, Int), Int, MultiFocus[Function1[Int, *]], SetterF]( - "MultiFocus.tuple[(Int,Int)].morph[SetterF] preserves modify (I1)", + checkAllMorphPreservesModifyFor[(Int, Int), Int, MultiFocus[Function1[Int, *]], ModifyF]( + "MultiFocus.tuple[(Int,Int)].morph[ModifyF] preserves modify (I1)", tuple2MultiFocusFnForMorph, - tuple2MultiFocusFnForMorph.morph[SetterF], + tuple2MultiFocusFnForMorph.morph[ModifyF], ) - // covers: Morph from MultiFocus[List] → SetterF on a List-shaped MultiFocus. + // covers: Morph from MultiFocus[List] → ModifyF on a List-shaped MultiFocus. // - // The `Composer[MultiFocus[F], SetterF]` (`multifocus2setter`, MultiFocus.scala) widens any - // MultiFocus-carrier optic to the Setter API. The MorphLaws.A1 check pins down that the lifted - // SetterF's `.modify(f)(s)` produces the same `T` as the original MultiFocus's `.modify(f)(s)` + // The `Composer[MultiFocus[F], ModifyF]` (`multifocus2modify`, MultiFocus.scala) widens any + // MultiFocus-carrier optic to the Modify API. The MorphLaws.A1 check pins down that the lifted + // ModifyF's `.modify(f)(s)` produces the same `T` as the original MultiFocus's `.modify(f)(s)` // via `mfFunctor` — the whole structural soundness of the bridge in one law. List is the // canonical multi-focus instance; the law covers the path `o.to(s) → mfFunctor.map(_, f) → // o.from(_)` end-to-end. val listMultiFocusForMorph: Optic[List[Int], List[Int], Int, Int, MultiFocus[List]] = MultiFocus.apply[List, Int] - checkAllMorphPreservesModifyFor[List[Int], Int, MultiFocus[List], SetterF]( - "MultiFocus.apply[List,Int].morph[SetterF] preserves modify (I1)", + checkAllMorphPreservesModifyFor[List[Int], Int, MultiFocus[List], ModifyF]( + "MultiFocus.apply[List,Int].morph[ModifyF] preserves modify (I1)", listMultiFocusForMorph, - listMultiFocusForMorph.morph[SetterF], + listMultiFocusForMorph.morph[ModifyF], ) // =============== B1 — Iso reverse involution ===================== diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala index de7f6c88..d95b06c2 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsBehaviorSpec.scala @@ -24,11 +24,11 @@ import optics.{ Optional, Prism, Review, - Setter, + Modify, Traversal, } import optics.Optic.* -import data.{Affine, Forget, Direct, MultiFocus, MultiFocusSingleton, PSVec, SetterF} +import data.{Affine, Forget, Direct, MultiFocus, MultiFocusSingleton, PSVec, ModifyF} /** Non-law behavioural coverage for EO's optics: exercises the extension methods (`andThen`, * `reverse`, `foldMap`, `modifyA`, `morph`), the Lens/Prism/Traversal alternative constructors, @@ -43,7 +43,7 @@ import data.{Affine, Forget, Direct, MultiFocus, MultiFocusSingleton, PSVec, Set * `AffineFold(o.getOption)`). * - 3 Lens-into-MultiFocus[List] specs (Tuple2/Either/Affine) collapsed via the same parametric * hit/miss assertion shape. - * - 3 SetterF lift specs (Either/Affine/PowerSeries → SetterF) collapsed similarly. + * - 3 ModifyF lift specs (Either/Affine/PowerSeries → ModifyF) collapsed similarly. * - 3 mfFold cardinality specs collapsed. * - 4 Optional.fused-andThen specs (Optional/GetReplaceLens/MendTearPrism/BijectionIso) * collapsed via parametric hit/miss assertion. @@ -52,8 +52,8 @@ import data.{Affine, Forget, Direct, MultiFocus, MultiFocusSingleton, PSVec, Set * * '''2026-04-28 MultiFocus migration.''' Every reference to the deleted `AlgLens[F]` and * `Kaleidoscope` carriers is rewritten to `MultiFocus[F]`. The `AlgLensSingleton` tag is renamed - * to `MultiFocusSingleton`. The Kaleidoscope-list `morph[SetterF]` block is now exercised on - * `MultiFocus[List]` and `MultiFocus[ZipList]` via `multifocus2setter`. + * to `MultiFocusSingleton`. The Kaleidoscope-list `morph[ModifyF]` block is now exercised on + * `MultiFocus[List]` and `MultiFocus[ZipList]` via `multifocus2modify`. */ class OpticsBehaviorSpec extends Specification with ScalaCheck: @@ -318,9 +318,9 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: // Accessor / Morph machinery (not per-class fused overloads): a total reader (Lens / Iso, // `Accessor[F]`) yields a Getter; a partial one (Optional / AffineFold / Prism) composes its // `readOnly` projection through Morph-to-Affine and yields an AffineFold. Plus the write-side - // dual: any writable optic .andThen(Setter) morphs through `Composer[·, SetterF]` and stays a - // Setter. Not Affine-only and not per-class — one rule per side. - "any optic .andThen(Getter) projects to read-only (Getter / AffineFold); .andThen(Setter) stays a Setter" >> { + // dual: any writable optic .andThen(Modify) morphs through `Composer[·, ModifyF]` and stays a + // Modify. Not Affine-only and not per-class — one rule per side. + "any optic .andThen(Getter) projects to read-only (Getter / AffineFold); .andThen(Modify) stays a Modify" >> { val toStr = Getter[Int, String](_.toString) // total readers -> Getter @@ -351,17 +351,17 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val prismOk = (prismThen.getOption("42") === Some("42")).and(prismThen.getOption("nope") === None) - // write-side dual: any writable optic .andThen(Setter) -> Setter - val plusSetter = Setter[Int, Int, Int, Int](f => n => f(n)) - val lensThenSet: Setter[(Int, String), (Int, String), Int, Int] = - fstLens.andThen(plusSetter) + // write-side dual: any writable optic .andThen(Modify) -> Modify + val plusModify = Modify[Int, Int, Int, Int](f => n => f(n)) + val lensThenSet: Modify[(Int, String), (Int, String), Int, Int] = + fstLens.andThen(plusModify) val setLensOk = lensThenSet.modify(_ + 1)((7, "x")) === ((8, "x")) val ageOptW = Optional[AdultPerson, AdultPerson, Int, Int, Affine]( getOrModify = p => Either.cond(p.age >= 18, p.age, p), reverseGet = { case (_, a) => AdultPerson(a) }, ) - val optThenSet = ageOptW.andThen(plusSetter) + val optThenSet = ageOptW.andThen(plusModify) val setOptOk = (optThenSet.modify(_ + 1)(AdultPerson(20)) === AdultPerson(21)) .and(optThenSet.modify(_ + 1)(AdultPerson(15)) === AdultPerson(15)) // miss: unchanged @@ -485,27 +485,27 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: sameOk.and(liftedOk).and(composedOk) } - // ----- Lens / Prism / Optional → MultiFocus[List] / SetterF cross-carrier ---------------- + // ----- Lens / Prism / Optional → MultiFocus[List] / ModifyF cross-carrier ---------------- // // 2026-04-29 consolidation: dropped a standalone case class `AdultOptCarrier` (was unused). case class AdultOptCarrier(p: AdultPerson) - // ----- Prism / Optional / Traversal / MultiFocus.tuple / MultiFocus.representable → SetterF + // ----- Prism / Optional / Traversal / MultiFocus.tuple / MultiFocus.representable → ModifyF // - // 2026-04-29 consolidation: 2 SetterF-themed blocks → 1 composite. Both witness the - // canonical "lift into SetterF + .modify byte-for-byte agrees with the source carrier". + // 2026-04-29 consolidation: 2 ModifyF-themed blocks → 1 composite. Both witness the + // canonical "lift into ModifyF + .modify byte-for-byte agrees with the source carrier". // covers: Lens(Tuple2) lifts into MultiFocus[List] preserving .modify semantics, // Either Prism lifts into MultiFocus[List] preserves hit/miss, // Affine Optional lifts into MultiFocus[List] preserves hit/miss, // Optional.andThen(Forget[List]→MultiFocus[List]) classifier composes via affine2multifocus; - // Either Prism lifts into SetterF and preserves hit/miss, - // Affine Optional lifts into SetterF and preserves hit/miss, - // PowerSeries (MultiFocus[PSVec]) Traversal lifts into SetterF and applies f to every focus, - // MultiFocus.tuple lifts into SetterF and rebroadcasts via per-slot rebuild, + // Either Prism lifts into ModifyF and preserves hit/miss, + // Affine Optional lifts into ModifyF and preserves hit/miss, + // PowerSeries (MultiFocus[PSVec]) Traversal lifts into ModifyF and applies f to every focus, + // MultiFocus.tuple lifts into ModifyF and rebroadcasts via per-slot rebuild, // MultiFocus.representable over Representable[Function1[Boolean, *]] lifts identically - "Lens/Prism/Optional → MultiFocus[List] + → SetterF: cross-carrier lifts (one composite block)" >> { + "Lens/Prism/Optional → MultiFocus[List] + → ModifyF: cross-carrier lifts (one composite block)" >> { // ---- → MultiFocus[List] half (absorbed standalone test) ---- val fstLens: Optic[(Int, String), (Int, String), Int, Int, Tuple2] = Lens[(Int, String), Int](_._1, (s, a) => (a, s._2)) @@ -540,31 +540,31 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val composedMFOk = (composedMF.modify(_ + 1)(3) === 11).and(composedMF.modify(_ + 1)(-1) === -1) - // ---- → SetterF half ---- + // ---- → ModifyF half ---- val evenP: Optic[Int, Int, Int, Int, Either] = Prism[Int, Int](n => if n % 2 == 0 then Right(n) else Left(n), identity) - val eitherLifted: Optic[Int, Int, Int, Int, data.SetterF] = - summon[Composer[Either, data.SetterF]].to(evenP) + val eitherLifted: Optic[Int, Int, Int, Int, data.ModifyF] = + summon[Composer[Either, data.ModifyF]].to(evenP) val eitherOk = (eitherLifted.modify(_ + 10)(4) === 14).and(eitherLifted.modify(_ + 10)(5) === 5) - val affineLifted: Optic[AdultPerson, AdultPerson, Int, Int, data.SetterF] = - summon[Composer[Affine, data.SetterF]].to(adultOpt) + val affineLifted: Optic[AdultPerson, AdultPerson, Int, Int, data.ModifyF] = + summon[Composer[Affine, data.ModifyF]].to(adultOpt) val affineOk = (affineLifted.modify(_ + 1)(AdultPerson(25)) === AdultPerson(26)) .and(affineLifted.modify(_ + 1)(AdultPerson(12)) === AdultPerson(12)) val each: Optic[List[Int], List[Int], Int, Int, MultiFocus[PSVec]] = Traversal.each[List, Int] - val psLifted: Optic[List[Int], List[Int], Int, Int, data.SetterF] = - summon[Composer[MultiFocus[PSVec], data.SetterF]].to(each) + val psLifted: Optic[List[Int], List[Int], Int, Int, data.ModifyF] = + summon[Composer[MultiFocus[PSVec], data.ModifyF]].to(each) val psOk = (psLifted.modify(_ * 10)(List(1, 2, 3)) === List(10, 20, 30)) .and(psLifted.modify(_ * 10)(Nil) === Nil) val tupleMF: Optic[(Int, Int, Int), (Int, Int, Int), Int, Int, MultiFocus[Function1[Int, *]]] = MultiFocus.tuple[(Int, Int, Int), Int] - val tupleLifted: Optic[(Int, Int, Int), (Int, Int, Int), Int, Int, data.SetterF] = - summon[Composer[MultiFocus[Function1[Int, *]], data.SetterF]].to(tupleMF) + val tupleLifted: Optic[(Int, Int, Int), (Int, Int, Int), Int, Int, data.ModifyF] = + summon[Composer[MultiFocus[Function1[Int, *]], data.ModifyF]].to(tupleMF) val tupleOk = (tupleLifted.modify(_ + 1)((10, 20, 30)) === ((11, 21, 31))) .and(tupleLifted.modify(_ * 2)((1, 2, 3)) === ((2, 4, 6))) @@ -573,8 +573,8 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: import cats.instances.function.given val fnMF: Optic[Boolean => Int, Boolean => Int, Int, Int, MultiFocus[Function1[Boolean, *]]] = MultiFocus.representable[[a] =>> Boolean => a, Int] - val fnLifted: Optic[Boolean => Int, Boolean => Int, Int, Int, data.SetterF] = - summon[Composer[MultiFocus[Function1[Boolean, *]], data.SetterF]].to(fnMF) + val fnLifted: Optic[Boolean => Int, Boolean => Int, Int, Int, data.ModifyF] = + summon[Composer[MultiFocus[Function1[Boolean, *]], data.ModifyF]].to(fnMF) val srcFn: Boolean => Int = b => if b then 100 else 200 val modified: Boolean => Int = fnLifted.modify(_ + 1)(srcFn) val fnOk = (modified(true) === 101).and(modified(false) === 201) @@ -590,9 +590,9 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: .and(fnOk) } - // ----- MultiFocus[F] lifted into SetterF + Foldable-aggregated escape ---------------- + // ----- MultiFocus[F] lifted into ModifyF + Foldable-aggregated escape ---------------- // - // 2026-04-29 consolidation: 2 MultiFocus[F]→SetterF / read-only-escape blocks → 1. + // 2026-04-29 consolidation: 2 MultiFocus[F]→ModifyF / read-only-escape blocks → 1. // covers: MultiFocus[F] read-only escape via .foldMap (sum / count / empty) — the gap-#2 // closure shipped two ways: (a) extension methods (.foldMap / .headOption / .length / .exists) @@ -600,8 +600,8 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: // Forget[F]]` — the explicit carrier morph, defensive about the bidirectional pair with // `forget2multifocus` (works when the user routes via `summon[Composer[..]].to(o)` rather // than `.andThen` to avoid the Morph-resolution ambiguity). - // Plus: MultiFocus.apply[List] / MultiFocus.apply[ZipList] → SetterF element-wise modify. - "MultiFocus[F] read-only escape (foldMap, → Forget[F]) + List/ZipList → SetterF" >> { + // Plus: MultiFocus.apply[List] / MultiFocus.apply[ZipList] → ModifyF element-wise modify. + "MultiFocus[F] read-only escape (foldMap, → Forget[F]) + List/ZipList → ModifyF" >> { val listMF: Optic[List[Int], List[Int], Int, Int, MultiFocus[List]] = MultiFocus.apply[List, Int] @@ -614,8 +614,8 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: summon[Composer[MultiFocus[List], Forget[List]]].to(listMF) val foldReadOk = asFold.to(List(7, 8, 9)) === List(7, 8, 9) - val listLifted: Optic[List[Int], List[Int], Int, Int, SetterF] = - summon[Composer[MultiFocus[List], SetterF]].to(listMF) + val listLifted: Optic[List[Int], List[Int], Int, Int, ModifyF] = + summon[Composer[MultiFocus[List], ModifyF]].to(listMF) val listOk = (listLifted.modify(_ + 1)(List(1, 2, 3)) === List(2, 3, 4)) .and(listLifted.modify(_ * 10)(Nil) === Nil) @@ -624,8 +624,8 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: import cats.data.ZipList val zipMF: Optic[ZipList[Int], ZipList[Int], Int, Int, MultiFocus[ZipList]] = MultiFocus.apply[ZipList, Int] - val zipLifted: Optic[ZipList[Int], ZipList[Int], Int, Int, SetterF] = - summon[Composer[MultiFocus[ZipList], SetterF]].to(zipMF) + val zipLifted: Optic[ZipList[Int], ZipList[Int], Int, Int, ModifyF] = + summon[Composer[MultiFocus[ZipList], ModifyF]].to(zipMF) val zipOk = (zipLifted.modify(_ * 10)(ZipList(List(1, 2, 3))).value === List(10, 20, 30)) .and(zipLifted.modify(_ + 1)(ZipList(Nil)).value === List.empty[Int]) @@ -633,21 +633,21 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: sumOk.and(sizeOk).and(emptyOk).and(foldReadOk).and(listOk).and(zipOk) } - // ----- SetterF same-carrier composition (gap #4) --------------------- + // ----- ModifyF same-carrier composition (gap #4) --------------------- // - // SetterF can't ship `AssociativeFunctor[SetterF]` because the deferred-modify - // semantic doesn't fit composeTo/composeFrom. Instead, `SetterF.scala` ships - // a SetterF-specific `.andThen` extension that composes via direct function + // ModifyF can't ship `AssociativeFunctor[ModifyF]` because the deferred-modify + // semantic doesn't fit composeTo/composeFrom. Instead, `ModifyF.scala` ships + // a ModifyF-specific `.andThen` extension that composes via direct function // composition. Scala 3 picks the more-specific extension over the carrier- - // generic `Optic.andThen[F[_, _]]` whenever both sides are concretely SetterF. + // generic `Optic.andThen[F[_, _]]` whenever both sides are concretely ModifyF. - // covers: Lens lifted to SetterF .andThen Lens lifted to SetterF — composed + // covers: Lens lifted to ModifyF .andThen Lens lifted to ModifyF — composed // .modify agrees with sequential modify through both; - // Lens-into-SetterF .andThen Prism-into-SetterF — composed hit modifies + // Lens-into-ModifyF .andThen Prism-into-ModifyF — composed hit modifies // the inner focus; composed miss leaves the source unchanged; - // Lens-into-SetterF .andThen Optional-into-SetterF — same hit/miss shape + // Lens-into-ModifyF .andThen Optional-into-ModifyF — same hit/miss shape // under the Affine carrier - "SetterF same-carrier composition: setter.andThen(setter)" >> { + "ModifyF same-carrier composition: modify.andThen(modify)" >> { case class Inner(value: Int) case class Outer(inner: Inner, tag: String) @@ -656,12 +656,12 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val innerLens: Optic[Inner, Inner, Int, Int, Tuple2] = Lens[Inner, Int](_.value, (s, v) => s.copy(value = v)) - val outerSf: Optic[Outer, Outer, Inner, Inner, SetterF] = - summon[Composer[Tuple2, SetterF]].to(outerLens) - val innerSf: Optic[Inner, Inner, Int, Int, SetterF] = - summon[Composer[Tuple2, SetterF]].to(innerLens) + val outerSf: Optic[Outer, Outer, Inner, Inner, ModifyF] = + summon[Composer[Tuple2, ModifyF]].to(outerLens) + val innerSf: Optic[Inner, Inner, Int, Int, ModifyF] = + summon[Composer[Tuple2, ModifyF]].to(innerLens) - val composed: Optic[Outer, Outer, Int, Int, SetterF] = outerSf.andThen(innerSf) + val composed: Optic[Outer, Outer, Int, Int, ModifyF] = outerSf.andThen(innerSf) val src = Outer(Inner(10), "tag") val lensLensOk = @@ -671,10 +671,10 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: val evenP: Optic[Int, Int, Int, Int, Either] = Prism[Int, Int](n => if n % 2 == 0 then Right(n) else Left(n), identity) - val evenSf: Optic[Int, Int, Int, Int, SetterF] = - summon[Composer[Either, SetterF]].to(evenP) + val evenSf: Optic[Int, Int, Int, Int, ModifyF] = + summon[Composer[Either, ModifyF]].to(evenP) - val outerThenEven: Optic[Outer, Outer, Int, Int, SetterF] = + val outerThenEven: Optic[Outer, Outer, Int, Int, ModifyF] = outerSf.andThen(innerSf).andThen(evenSf) val lensPrismHit = outerThenEven.modify(_ + 100)(Outer(Inner(4), "x")) val lensPrismMiss = outerThenEven.modify(_ + 100)(Outer(Inner(5), "x")) @@ -687,10 +687,10 @@ class OpticsBehaviorSpec extends Specification with ScalaCheck: getOrModify = n => Either.cond(n > 0, n, n), reverseGet = { case (_, n) => n }, ) - val posSf: Optic[Int, Int, Int, Int, SetterF] = - summon[Composer[Affine, SetterF]].to(posOpt) + val posSf: Optic[Int, Int, Int, Int, ModifyF] = + summon[Composer[Affine, ModifyF]].to(posOpt) - val outerThenPos: Optic[Outer, Outer, Int, Int, SetterF] = + val outerThenPos: Optic[Outer, Outer, Int, Int, ModifyF] = outerSf.andThen(innerSf).andThen(posSf) val lensOptHit = outerThenPos.modify(_ * 10)(Outer(Inner(3), "y")) val lensOptMiss = outerThenPos.modify(_ * 10)(Outer(Inner(-3), "y")) diff --git a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala index f2cbae81..13c120f3 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala @@ -8,9 +8,30 @@ import org.scalacheck.Prop.forAll import org.scalacheck.{Arbitrary, Cogen, Gen} import org.specs2.mutable.Specification -import optics.{AffineFold, Fold, Getter, Iso, Lens, Optic, Optional, Prism, Setter, Traversal} -import data.{Affine, Forget, Direct, MultiFocus, PSVec, SetterF} -import laws.{AffineFoldLaws, GetterLaws, IsoLaws, LensLaws, OptionalLaws, PrismLaws, SetterLaws} +import optics.{ + AffineFold, + Fold, + Getter, + Iso, + Lens, + Optic, + Optional, + Prism, + Modify, + Traversal, + Unfold, +} +import data.{Affine, Forget, Direct, MultiFocus, PSVec, ModifyF} +import laws.{ + AffineFoldLaws, + GetterLaws, + IsoLaws, + LensLaws, + OptionalLaws, + PrismLaws, + ModifyLaws, + UnfoldLaws, +} import laws.discipline.{ AffineFoldTests, GetterTests, @@ -18,10 +39,11 @@ import laws.discipline.{ LensTests, OptionalTests, PrismTests, - SetterTests, + ModifyTests, + UnfoldTests, } -import laws.data.{AffineLaws, SetterFLaws} -import laws.data.discipline.{AffineTests, SetterFTests} +import laws.data.{AffineLaws, ModifyFLaws} +import laws.data.discipline.{AffineTests, ModifyFTests} import laws.typeclass.AssociativeFunctorLaws import laws.typeclass.discipline.AssociativeFunctorTests @@ -38,6 +60,18 @@ private given arbAffineIntStringBool: Arbitrary[Affine[(Int, String), Boolean]] ) ) +// Arbitrary[BinF[Int]] — equal-weight leaf / branch layers of the UnfoldSpec pattern functor. +private given arbBinFInt: Arbitrary[BinF[Int]] = + Arbitrary( + Gen.oneOf( + Arbitrary.arbitrary[Int].map(BinF.LeafF(_)), + for + l <- Arbitrary.arbitrary[Int] + r <- Arbitrary.arbitrary[Int] + yield BinF.BranchF(l, r), + ) + ) + /** End-to-end check that EO's optics satisfy the Monocle-style discipline laws. Each block * constructs a concrete instance and feeds it to the matching `*Tests` runner in * `dev.constructive.eo.laws.OpticLaws`. @@ -129,17 +163,17 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: .optional, ) - // ----- Setter: maps `f` over both sides of a pair -------------- + // ----- Modify: maps `f` over both sides of a pair -------------- - val pairSetter: Optic[(Int, Int), (Int, Int), Int, Int, data.SetterF] = - Setter[(Int, Int), (Int, Int), Int, Int](f => { case (a, b) => (f(a), f(b)) }) + val pairModify: Optic[(Int, Int), (Int, Int), Int, Int, data.ModifyF] = + Modify[(Int, Int), (Int, Int), Int, Int](f => { case (a, b) => (f(a), f(b)) }) checkAll( - "Setter[(Int,Int), Int] — both pair components", - new SetterTests[(Int, Int), Int]: - val laws = new SetterLaws[(Int, Int), Int]: - val setter = pairSetter - .setter, + "Modify[(Int,Int), Int] — both pair components", + new ModifyTests[(Int, Int), Int]: + val laws = new ModifyLaws[(Int, Int), Int]: + val modify = pairModify + .modify, ) // ----- Traversal.each on List[Int] ------------------------------ @@ -209,6 +243,38 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: toOk && neverOk } + // ----- Unfold: build-only/many — Applicative carrier + pattern functor ----- + + val sumUnfold: Unfold[Int, Int, List] = Unfold((xs: List[Int]) => xs.sum) + + // covers: Unfold.apply over List (Applicative carrier — full RuleSet incl. the vestigial law) + checkAll( + "Unfold[Int, Int, List] — sum", + new UnfoldTests[Int, Int, List]: + val laws = new UnfoldLaws[Int, Int, List]: + val unfold = sumUnfold + val reference = (xs: List[Int]) => xs.sum + .unfoldApplicative, + ) + + val evalUnfold: Unfold[Int, Int, BinF] = Unfold.algebra[Int, Int, BinF] { + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + } + + // covers: Unfold.algebra over a pattern functor (Functor-only RuleSet — no Applicative[BinF]) + checkAll( + "Unfold[Int, Int, BinF] — pattern-functor algebra", + new UnfoldTests[Int, Int, BinF]: + val laws = new UnfoldLaws[Int, Int, BinF]: + val unfold = evalUnfold + val reference = (fb: BinF[Int]) => + fb match + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + .unfold, + ) + // ----- AffineFold: partial projection + filtering select ------- val adultAgeAF: AffineFold[(Int, String), Int] = @@ -241,13 +307,13 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: .affine, ) - // ----- SetterF carrier laws ------------------------------------- + // ----- ModifyF carrier laws ------------------------------------- checkAll( - "SetterF[(Int, String), Boolean]", - new SetterFTests[(Int, String), Boolean]: - val laws = new SetterFLaws[(Int, String), Boolean] {} - .setterF, + "ModifyF[(Int, String), Boolean]", + new ModifyFTests[(Int, String), Boolean]: + val laws = new ModifyFLaws[(Int, String), Boolean] {} + .modifyF, ) // ----- MultiFocus[PSVec] carrier carrier laws ------------------- @@ -279,7 +345,7 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: // // No carrier-level discipline block is added for `MultiFocus[Function1[Int, *]]` // because structural `==` on functions is reference equality — exactly the - // problem SetterF had. The functor laws are instead witnessed extensionally by + // problem ModifyF had. The functor laws are instead witnessed extensionally by // `MultiFocusFunction1Spec` (G1/G2 on `MultiFocus.tuple`, the same carrier) and // by `EoSpecificLawsSpec`'s "Traversal.two / three modifies …" forAll blocks. @@ -375,10 +441,10 @@ class OpticsLawsSpec extends Specification with CheckAllHelpers: ) // Use a custom extensional equality check inside the law — structural - // `==` on SetterF compares closures by identity which is too strict. + // `==` on ModifyF compares closures by identity which is too strict. // We sample the builder at a fixed Snd input to witness identity / // composition. The shared ForgetfulFunctorLaws uses `==`, so we wire - // the SetterF-specific laws directly (from dev.constructive.eo.laws.data) rather than + // the ModifyF-specific laws directly (from dev.constructive.eo.laws.data) rather than // the carrier-generic ones. // MultiFocus[F] carrier-level laws for F in {List, Option, Vector, Chain} — pins down the diff --git a/tests/src/test/scala/dev/constructive/eo/PlatedSpec.scala b/tests/src/test/scala/dev/constructive/eo/PlatedSpec.scala index 0cb75d8b..2a72c0a9 100644 --- a/tests/src/test/scala/dev/constructive/eo/PlatedSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/PlatedSpec.scala @@ -124,7 +124,7 @@ class PlatedSpec extends Specification with Discipline: // ----- The headline: a recursive `everywhere` optic that composes with Prism + Lens ----- - "everywhere (Setter modify = transform) composes with a Prism + Lens to rewrite ALL variables" >> { + "everywhere (Modify modify = transform) composes with a Prism + Lens to rewrite ALL variables" >> { val varP = Prism[Expr, Expr.Var]( { case v: Expr.Var => Right(v) diff --git a/tests/src/test/scala/dev/constructive/eo/Samples.scala b/tests/src/test/scala/dev/constructive/eo/Samples.scala index e17c3aaf..8ff515c0 100644 --- a/tests/src/test/scala/dev/constructive/eo/Samples.scala +++ b/tests/src/test/scala/dev/constructive/eo/Samples.scala @@ -44,4 +44,4 @@ case class UP(a: Int, b: Boolean) println(t.modify(_ + 1)(l)) println(t.modifyA(a => (a % 2).asRight[String])(l)) println(Fold.select[Int](_ % 2 == 0).to(4)) - println(Setter[Double, Double, Int, Int](fd).modify(_ + 1)(1.23)) + println(Modify[Double, Double, Int, Int](fd).modify(_ + 1)(1.23)) diff --git a/tests/src/test/scala/dev/constructive/eo/UnfoldSpec.scala b/tests/src/test/scala/dev/constructive/eo/UnfoldSpec.scala new file mode 100644 index 00000000..10148b3c --- /dev/null +++ b/tests/src/test/scala/dev/constructive/eo/UnfoldSpec.scala @@ -0,0 +1,104 @@ +package dev.constructive.eo + +import scala.language.implicitConversions + +import cats.Functor +import cats.instances.list.given +import org.specs2.mutable.Specification + +import optics.{BijectionIso, Optic, Review, Unfold} +import data.Forget + +/** One-layer pattern functor for the `Unfold.algebra` cases — `Functor` but deliberately NO + * `Applicative` (`pure` cannot pick a constructor), the shape that motivates the constraint-free + * [[Unfold.algebra]] factory. + */ +enum BinF[+A]: + case LeafF(n: Int) + case BranchF(l: A, r: A) + +object BinF: + + given Functor[BinF] with + + def map[A, B](fa: BinF[A])(f: A => B): BinF[B] = fa match + case BinF.LeafF(n) => BinF.LeafF(n) + case BinF.BranchF(l, r) => BinF.BranchF(f(l), f(r)) + +/** Behaviour of [[optics.Unfold]] — the build-only / many citizen (`embed: F[B] => T` on the + * `Forget[F]` carrier) — and of the composition seams it opens: + * + * - the fused `Review.andThen(Unfold)` / `Unfold.andThen(Review)` / `Unfold.andThen(Unfold)` + * members, + * - the generic `Morph`-routed `direct.andThen(unfold)` chain, whose build side executes + * `Composer.direct2forget`'s `from` — the branch that was a documented-unreachable `???` until + * `Unfold` inhabited the cell. + */ +class UnfoldSpec extends Specification: + + private val sum: Unfold[Int, Int, List] = Unfold((xs: List[Int]) => xs.sum) + + private val evalAlg: Unfold[Int, Int, BinF] = Unfold.algebra[Int, Int, BinF] { + case BinF.LeafF(n) => n + case BinF.BranchF(l, r) => l + r + } + + "Unfold.embed assembles one whole from a layer of parts" >> { + sum.embed(List(1, 2, 3)) === 6 + } + + "Unfold.algebra carries a pattern-functor algebra (no Applicative anywhere)" >> { + evalAlg.embed(BinF.BranchF(2, 3)) === 5 + evalAlg.embed(BinF.LeafF(7)) === 7 + } + + "Review.andThen(Unfold) post-processes the assembled whole (fused, constraint-free)" >> { + val show = Review[String, Int](_.toString) + val composite: Unfold[String, Int, BinF] = show.andThen(evalAlg) + composite.embed(BinF.BranchF(2, 3)) === "5" + } + + "Unfold.andThen(Review) pre-processes each part (fused, Functor only)" >> { + val parse = Review[Int, String](_.toInt) + val composite: Unfold[Int, String, BinF] = evalAlg.andThen(parse) + composite.embed(BinF.BranchF("2", "3")) === 5 + } + + "Unfold.andThen(Unfold) layers algebras via the algebraic-lens pull (Applicative)" >> { + val negate: Unfold[Int, Int, List] = Unfold((xs: List[Int]) => -xs.sum) + // outer consumes the singleton re-lift of the inner's result: -(sum [1,2,3]) = -6 + negate.andThen(sum).embed(List(1, 2, 3)) === -6 + } + + "reversible outer ∘ unfold lands the fused Unfold (Iso build half re-homes the whole)" >> { + val render: BijectionIso[Unit, String, Unit, Int] = + new BijectionIso[Unit, String, Unit, Int](identity, _.toString) + val composite: Unfold[String, Int, List] = render.andThen(sum) + composite.embed(List(1, 2, 3)) === "6" + } + + "unfold ∘ reversible inner mends each part through a Prism's build half" >> { + val positive = optics.Prism[Int, Int](n => if n > 0 then Right(n) else Left(n), identity) + val composite: Unfold[Int, Int, List] = sum.andThen(positive) + composite.embed(List(1, 2, 3)) === 6 + } + + "the morph-routed Forget chain reaches direct2forget's formerly-??? from soundly" >> { + // Route a Direct-carried builder through Composer[Direct, Forget[List]] explicitly, then + // compose under the shared Forget[List] carrier (assocForgetMonad) — its composeFrom feeds + // the lifted optic's from a pure-singleton, executing the branch that was `???`. + val render: BijectionIso[Unit, String, Unit, Int] = + new BijectionIso[Unit, String, Unit, Int](identity, _.toString) + val lifted: Optic[Unit, String, Unit, Int, Forget[List]] = render.morph[Forget[List]] + val composite: Optic[Unit, String, Unit, Int, Forget[List]] = lifted.andThen(sum) + composite.from(Forget(List(1, 2, 3))) === "6" + } + + "read-side operations on an Applicative Unfold degrade to the singleton layer" >> { + // .modify routes Unit through the vestigial to = pure(()): embed(pure(b)) + sum.modify(_ => 5)(()) === 5 + } + + "read-side operations on a pattern-functor Unfold fail loudly" >> { + evalAlg.modify(_ => 1)(()) must throwAn[UnsupportedOperationException] + }