From 464bbd6808ca7c75f26b6300bf215be247e799f8 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 01:41:55 +0200 Subject: [PATCH 01/12] =?UTF-8?q?feat(core):=20Unfold=20=E2=80=94=20the=20?= =?UTF-8?q?build-only/many=20optic=20citizen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inhabits the last empty cell of the focus-shape × capability lattice: Unfold[T, B, F] = Optic[Unit, T, Unit, B, Forget[F]] whose real map is embed: F[B] => T — the algebra of a recursion scheme and the "assemble one whole from many parts" arrow, as Review is to Getter on the many rung (spike: docs/brainstorms/2026-06-10-unfold-build-many-citizen.md). - core: final-class Unfold with two factories — apply (Applicative carriers; honest vestigial to = pure(())) and algebra (pattern functors, which admit no Applicative; vestigial to fails loudly). Fused andThen members: Unfold∘Review (Functor), Unfold∘Unfold (Applicative, algebraic-lens pull), and the generalized Unfold ∘ any-reversible-inner (ReverseAccessor[G]). - core: Review.andThen(Unfold) fused member + the reversible-outer ∘ Unfold extension — the many-rung mirrors of the Review collapses. - core: direct2forget's documented-unreachable `???` from is now a sound branch (Foldable[F] singleton-pick; the only reachable F[B] is monadicPull's pure(b)). Unfold made the branch reachable. - laws: UnfoldLaws + UnfoldTests — constructor-correctness, pre/post-compose coherence, vestigial singleton degradation (Applicative-only RuleSet); registered in OpticsLawsSpec for a List carrier and a pattern functor. - tests: composition matrix extended to the 11-family grid (121 cells); the Unfold row/column mirrors Review's reversibility pattern. UnfoldSpec pins behaviour incl. the formerly-??? path. - schemes: cata overload consuming a pure PSVec algebra carried as an Unfold (node-blind by design; the typed path is where pure algebras are fully expressive — Embed[F, S] ≅ Unfold[S, S, F], future work). Co-Authored-By: Claude Fable 5 --- .../constructive/eo/compose/Composer.scala | 28 ++-- .../dev/constructive/eo/data/MultiFocus.scala | 2 +- .../dev/constructive/eo/optics/Optic.scala | 9 ++ .../dev/constructive/eo/optics/Review.scala | 10 ++ .../dev/constructive/eo/optics/Unfold.scala | 129 ++++++++++++++++++ .../2026-06-10-unfold-build-many-citizen.md | 69 +++++++++- .../dev/constructive/eo/laws/UnfoldLaws.scala | 50 +++++++ .../eo/laws/discipline/UnfoldTests.scala | 49 +++++++ .../dev/constructive/eo/schemes/Schemes.scala | 15 +- .../constructive/eo/schemes/SchemesSpec.scala | 15 +- .../eo/CompositionMatrixSpec.scala | 81 +++++++++++ .../dev/constructive/eo/OpticsLawsSpec.scala | 70 +++++++++- .../dev/constructive/eo/UnfoldSpec.scala | 104 ++++++++++++++ 13 files changed, 616 insertions(+), 15 deletions(-) create mode 100644 core/src/main/scala/dev/constructive/eo/optics/Unfold.scala create mode 100644 laws/src/main/scala/dev/constructive/eo/laws/UnfoldLaws.scala create mode 100644 laws/src/main/scala/dev/constructive/eo/laws/discipline/UnfoldTests.scala create mode 100644 tests/src/test/scala/dev/constructive/eo/UnfoldSpec.scala 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..dc49e661 100644 --- a/core/src/main/scala/dev/constructive/eo/compose/Composer.scala +++ b/core/src/main/scala/dev/constructive/eo/compose/Composer.scala @@ -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 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..e9f78b0b 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 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..07944da2 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala @@ -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) 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..18d9519c 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Review.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Review.scala @@ -43,6 +43,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/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/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/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/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/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/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala b/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala index 01e1a92e..58ff4a9c 100644 --- a/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala @@ -66,6 +66,8 @@ object MatrixFixtures: val i_fold = Fold[List, Int] val i_setter = Setter[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.* @@ -125,6 +127,10 @@ class CompositionMatrixSpec extends Specification: 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" >> { @@ -181,6 +187,9 @@ class CompositionMatrixSpec extends Specification: "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" >> { @@ -238,6 +247,10 @@ class CompositionMatrixSpec extends Specification: 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" >> { @@ -294,6 +307,9 @@ class CompositionMatrixSpec extends Specification: "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" >> { @@ -354,6 +370,9 @@ class CompositionMatrixSpec extends Specification: "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" >> { @@ -399,6 +418,9 @@ class CompositionMatrixSpec extends Specification: "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" >> { @@ -444,6 +466,9 @@ class CompositionMatrixSpec extends Specification: "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" >> { @@ -501,6 +526,9 @@ class CompositionMatrixSpec extends Specification: "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" >> { @@ -552,6 +580,9 @@ class CompositionMatrixSpec extends Specification: "setter ∘ review must not compile" >> { typeChecks("o_setter.andThen(i_review)") must beFalse } + "setter ∘ unfold must not compile" >> { + typeChecks("o_setter.andThen(i_unfold)") must beFalse + } } "review (outer) composition row" >> { @@ -588,4 +619,54 @@ class CompositionMatrixSpec extends Specification: 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 ∘ setter must not compile" >> { + typeChecks("o_unfold.andThen(i_setter)") 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/OpticsLawsSpec.scala b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala index f2cbae81..a72d3e98 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 optics.{ + AffineFold, + Fold, + Getter, + Iso, + Lens, + Optic, + Optional, + Prism, + Setter, + Traversal, + Unfold, +} import data.{Affine, Forget, Direct, MultiFocus, PSVec, SetterF} -import laws.{AffineFoldLaws, GetterLaws, IsoLaws, LensLaws, OptionalLaws, PrismLaws, SetterLaws} +import laws.{ + AffineFoldLaws, + GetterLaws, + IsoLaws, + LensLaws, + OptionalLaws, + PrismLaws, + SetterLaws, + UnfoldLaws, +} import laws.discipline.{ AffineFoldTests, GetterTests, @@ -19,6 +40,7 @@ import laws.discipline.{ OptionalTests, PrismTests, SetterTests, + UnfoldTests, } import laws.data.{AffineLaws, SetterFLaws} import laws.data.discipline.{AffineTests, SetterFTests} @@ -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`. @@ -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] = 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] + } From 54bfdac99e682b1e69ecd69843c3ae19732f91bf Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 02:14:12 +0200 Subject: [PATCH 02/12] docs: composition matrix + Unfold taxonomy on the site; stale-info sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site: - optics.md: family taxonomy redrawn with all three one-way rungs (read-only Getter→AffineFold→Fold, build-only Review→Unfold, write-only Setter) and the collapse rules; NEW full 11-family / 121-cell composition matrix table (pinned by CompositionMatrixSpec) with the void-structure rationale; NEW Unfold reference section (both factories, pattern-functor composition, mdoc-run examples); ReadOnly[F] → ReadCompose, DirectGetter → Getter, AffineFold alias corrected to B = Unit, "lens ∘ affineFold doesn't type-check" corrected (it composes via ReadCompose), removed the cross-F `~>` Fold extension section (the extension no longer exists — the read-collapse covers it), pointers re-aimed at the spec. - concepts.md: Direct → Forget[F] edge added to the Composer lattice (direct2forget, now with a sound build side); carrier table gains Review (Direct) and Unfold (Forget[F]). - schemes.md: "the algebra as an optic" — cata(Unfold) overload with the node-blind honesty note. - migration-from-monocle.md: "Getter/Setter don't compose" replaced with the collapse story; Review/Unfold no-Monocle-equivalent row. - multifocus.md / index.md / benchmarks.md: matrix pointers freshened, DirectGetter → Getter. Scaladoc: - Lens: broken [[DirectGetter.andThen]] link → [[Getter.andThen]]. - Optic companion: extension catalogue mentions the Unfold collapse. - Review / Fold / Forget: cross-links to the Unfold dual. - GetterLaws: direct2forget parenthetical updated (sound pick, not "reachably-unsound-free"). Co-Authored-By: Claude Fable 5 --- .../dev/constructive/eo/data/Forget.scala | 3 +- .../dev/constructive/eo/optics/Fold.scala | 3 +- .../dev/constructive/eo/optics/Lens.scala | 4 +- .../dev/constructive/eo/optics/Optic.scala | 2 +- .../dev/constructive/eo/optics/Review.scala | 3 + .../dev/constructive/eo/laws/GetterLaws.scala | 7 +- site/docs/benchmarks.md | 2 +- site/docs/concepts.md | 18 +- site/docs/index.md | 3 +- site/docs/migration-from-monocle.md | 19 +- site/docs/multifocus.md | 6 +- site/docs/optics.md | 296 ++++++++++++++---- site/docs/schemes.md | 30 ++ 13 files changed, 306 insertions(+), 90 deletions(-) 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/optics/Fold.scala b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala index 80a41250..ff7d3e05 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]]). 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/Optic.scala b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala index 07944da2..defb3513 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Optic.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Optic.scala @@ -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 / Setter / Review / Unfold collapses), `.readOnly`, `.cross`, `.morph`, * `.headOption`, `.length`, `.exists`. Adding a new carrier means supplying the typeclass * instances of the operations it should support. */ 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 18d9519c..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 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/site/docs/benchmarks.md b/site/docs/benchmarks.md index b2830859..0da4396f 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -114,7 +114,7 @@ Some and None branches. **Getter / Setter** (`Direct` / `SetterF`) — 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` / `SetterOptic`), so every row builds a *composed* optic on both sides and dispatches through it once — apples-to-apples with Monocle's composed `Getter`/`Setter`. diff --git a/site/docs/concepts.md b/site/docs/concepts.md index 959193d9..9082d719 100644 --- a/site/docs/concepts.md +++ b/site/docs/concepts.md @@ -58,12 +58,12 @@ 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` | +| `Forget[F]` | `F[A]` — an `F`-layer with no leftover | `Fold` (read-only, `F: Foldable`), `Unfold` (build-only, `embed: F[B] => T`) | | `SetterF` | `(Fst[X], Snd[X] => A)` | `Setter` | What a carrier supports is *exactly* what its typeclass @@ -197,13 +197,14 @@ flowchart LR Direct --> Tuple2 Direct --> Either Direct --> MFocus["MultiFocus[F]"] + Direct --> ForgetF["Forget[F]"] Tuple2 --> Affine Tuple2 --> SetterF Tuple2 --> MFocus Either --> Affine Either --> MFocus Affine --> MFocus - ForgetF["Forget[F]"] --> MFocus + ForgetF --> MFocus MFocus --> SetterF MFocus -.->|read-only, T=Unit| ForgetF Direct -.->|chainViaTuple2| Affine @@ -213,10 +214,13 @@ flowchart LR 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/index.md b/site/docs/index.md index c993e188..95362e75 100644 --- a/site/docs/index.md +++ b/site/docs/index.md @@ -62,7 +62,8 @@ 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 diff --git a/site/docs/migration-from-monocle.md b/site/docs/migration-from-monocle.md index dd8e0a09..c89cb6c9 100644 --- a/site/docs/migration-from-monocle.md +++ b/site/docs/migration-from-monocle.md @@ -17,6 +17,7 @@ plus a note on where EO diverges. | *(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 => …)` | +| *(no equivalent — build-only optics are not standalone citizens in Monocle)* | `Review[S, A](build)` (build-only, one focus) and `Unfold[T, B, F]` (build-only, many: `embed: F[B] => T` — recursion-scheme algebras, aggregation); see [Optics → Review](optics.md#review) / [Unfold](optics.md#unfold) | | `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) | @@ -79,13 +80,17 @@ 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. -### Getter / Setter don't compose via `.andThen` - -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. +### Getter / Setter compose by collapse, not by carrier + +Getter's `T = Unit` and Setter's `SetterF` carrier share no +`AssociativeFunctor` instance — instead, a chain that touches a +read-only optic anywhere collapses to the read-only join +(`lens.andThen(getter)` → Getter, `prism.andThen(getter)` → +AffineFold, `traversal.andThen(getter)` → Fold), and a chain into +a Setter collapses the read side (`lens.andThen(setter)` → +Setter). `getter.andThen(setter)` itself is void by design — there +is nothing to write through. See the +[composition matrix](optics.md#composition-matrix) for every pair. ### Traversal carrier diff --git a/site/docs/multifocus.md b/site/docs/multifocus.md index a5f9464b..b22cd912 100644 --- a/site/docs/multifocus.md +++ b/site/docs/multifocus.md @@ -371,8 +371,10 @@ on the worktree branch that landed it: 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..6f3e6a65 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -25,15 +25,25 @@ flowchart TD Iso --> MultiFocus end - subgraph single["Single direction"] - Getter["Getter — read-only"] - Setter["Setter — write-only"] - Review["Review — build-only"] + subgraph readonly["Read-only"] + Getter --> AffineFold + AffineFold --> Fold end + subgraph buildonly["Build-only"] + Review --> Unfold["Unfold[F]"] + end + + Setter["Setter — write-only"] + Iso --> Getter Lens --> Getter + Prism --> AffineFold + Affine --> AffineFold + MultiFocus --> Fold MultiFocus --> Setter + Iso --> Review + Prism --> Review click Iso "#iso" click Lens "#lens" @@ -43,6 +53,9 @@ flowchart TD click Getter "#getter" click Setter "#setter" click Review "#review" + click Unfold "#unfold" + click AffineFold "#affinefold" + click Fold "#fold" ``` How to read it: @@ -52,17 +65,73 @@ How to read it: - **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. + carries both a read and a write side. **One-way optics** keep only + one: the read-only rung (Getter → AffineFold → Fold, ordered by how + many foci a read can produce: exactly one, 0-or-1, many), the + build-only rung (Review → Unfold, one focus vs. an `F`-layer of + parts), and write-only Setter. +- Composing **into a read-only inner** (or from a read-only outer) + drops every write side and lands on the read-only rung at the join + of the two read strengths — `lens ∘ getter` → Getter, + `prism ∘ getter` → AffineFold, `traversal ∘ getter` → Fold. +- Composing **through the build side** keeps only build halves: + reversible outers (Iso / Prism / Review — anything whose carrier has + a `ReverseAccessor`) compose into Review and Unfold; + `review ∘ unfold` and `unfold ∘ review` land on Unfold. +- Composing with a write-only Setter collapses the read side: + `lens ∘ setter` → Setter. `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 | Setter | Review | Unfold | +|---------------|-----|------|-------|----------|-----------|--------|------------|------|--------|--------|--------| +| **Iso** | Iso | Lens | Prism | Optional | Traversal | Getter | AffineFold | Fold | Setter | Review | Unfold | +| **Lens** | Lens | Lens | Optional | Optional | Traversal | Getter | AffineFold | Fold | Setter | ∅ | ∅ | +| **Prism** | Prism | Optional | Prism | Optional | Traversal | AffineFold | AffineFold | Fold | Setter | Review | Unfold | +| **Optional** | Optional | Optional | Optional | Optional | Traversal | AffineFold | AffineFold | Fold | Setter | ∅ | ∅ | +| **Traversal** | Traversal | Traversal | Traversal | Traversal | Traversal | Fold | Fold | Fold | Setter | ∅ | ∅ | +| **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 | ∅ | ∅ | ∅ | +| **Setter** | Setter | Setter | Setter | Setter | Setter | ∅ | ∅ | ∅ | Setter | ∅ | ∅ | +| **Review** | Review | ∅ | Review | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | Review | Unfold | +| **Unfold** | Unfold | ∅ | Unfold | ∅ | ∅ | ∅ | ∅ | ∅ | ∅ | Unfold | Unfold | + +The structure of the voids is the taxonomy speaking: + +- The **Setter column/row corner**: a Setter 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} @@ -191,17 +260,23 @@ 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) ``` +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 `Setter` collapses the *read* side and yields a `Setter` (`lens.andThen(setter)`, `optional.andThen(setter)`, …) — it modifies the focus through the @@ -333,7 +408,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 @@ -472,19 +547,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 +692,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 +727,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,37 +745,25 @@ 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`. +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. **`SetterF` outbound** — Setter is a write-side terminal: there is no outbound `Composer[SetterF, _]`, so a chain that reaches Setter cannot @@ -617,5 +779,11 @@ MF[Function1[Int, *]]`, `MF[Function1[Int, *]] ↪ SetterF`, 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 From ca7874744631d614985f5c7fc9c75d2a5b05882a Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 07:41:09 +0200 Subject: [PATCH 03/12] refactor(core)!: rename Setter/SetterF to Modify/ModifyF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write-only family's old name overlapped with the build group's vocabulary: a "Setter" sounds like it sets/constructs, but the family is the modify capability — `(A => B) => S => T` — with no build side at all (Review / Unfold own building). Renaming makes the taxonomy's three one-way rungs unambiguous: read-only (Getter/AffineFold/Fold), build-only (Review/Unfold), write-only (Modify). - core: `optics.Setter` → `optics.Modify` (final class + companion), `data.SetterF` → `data.ModifyF` (carrier; stored pair accessor `setter` → `modifier`); given names follow (`assocModifyF`, `tuple2modify`, `either2modify`, `affine2modify`, `multifocus2modify`, `coerceToModify`). - laws: `SetterLaws`/`SetterTests` → `ModifyLaws`/`ModifyTests` (RuleSet method `setter` → `modify`); `SetterFLaws`/`SetterFTests` → `ModifyFLaws`/`ModifyFTests`. The Monocle provenance note keeps pointing at `monocle.law.SetterLaws` (their name). - tests: composition-matrix row/cells renamed (`modify ∘ …`), fixtures `o_setter`/`i_setter` → `o_modify`/`i_modify`; behaviour spec titles updated. - benchmarks: eo-side alias `EoSetter` → `EoModify` and carrier refs updated; `SetterBench` CLASS NAME and method names kept verbatim so CI benchmark tracking history stays continuous (Monocle's family is still `Setter` — `MSetter`/`mSet` untouched). - site + README: family list, taxonomy diagram, composition matrix, carrier tables, Composer lattice, migration guide (Monocle `Setter` → eo `Modify` row; left column keeps Monocle's name), anchors `#setter` → `#modify`. Also fixed two stale claims found in the sweep: concepts.md still said Review sits outside the Optic trait (false since the Review→Optic fold-in), and benchmarks.md still named `SetterOptic` (a class renamed before this change). README family list also gains the missing Unfold entry. No deprecation shims: no published baseline exists (mima.sbt — 0.1.0 has no previous version), so the rename is a pre-release cleanup. Co-Authored-By: Claude Fable 5 --- README.md | 5 +- .../eo/bench/CompositionBench.scala | 2 +- .../constructive/eo/bench/SetterBench.scala | 10 +- .../eo/bench/fixture/Domain.scala | 2 +- .../eo/bench/fixture/DomainOptics.scala | 8 +- .../eo/bench/fixture/Nested.scala | 2 +- .../eo/bench/fixture/NestedOptics.scala | 20 +-- .../dev/constructive/eo/bench/package.scala | 2 +- .../constructive/eo/compose/Composer.scala | 4 +- .../dev/constructive/eo/data/ModifyF.scala | 149 ++++++++++++++++++ .../dev/constructive/eo/data/MultiFocus.scala | 14 +- .../dev/constructive/eo/data/SetterF.scala | 149 ------------------ .../eo/forgetful/ForgetfulFold.scala | 2 +- .../eo/forgetful/ForgetfulTraverse.scala | 4 +- .../dev/constructive/eo/optics/Fold.scala | 3 +- .../dev/constructive/eo/optics/Modify.scala | 63 ++++++++ .../dev/constructive/eo/optics/Optic.scala | 8 +- .../dev/constructive/eo/optics/Plated.scala | 10 +- .../dev/constructive/eo/optics/Setter.scala | 63 -------- .../dev/constructive/eo/MultiFocusSpec.scala | 20 +-- .../dev/constructive/eo/laws/ModifyLaws.scala | 28 ++++ .../dev/constructive/eo/laws/SetterLaws.scala | 28 ---- .../{SetterFLaws.scala => ModifyFLaws.scala} | 30 ++-- ...{SetterFTests.scala => ModifyFTests.scala} | 16 +- .../eo/laws/discipline/LensTests.scala | 2 +- .../{SetterTests.scala => ModifyTests.scala} | 14 +- .../internal/ReplaceLawsTests.scala | 6 +- .../laws/typeclass/ForgetfulFunctorLaws.scala | 6 +- .../typeclass/ForgetfulTraverseLaws.scala | 2 +- site/docs/avro.md | 2 +- site/docs/benchmarks.md | 10 +- site/docs/concepts.md | 34 ++-- site/docs/extensibility.md | 2 +- site/docs/generics.md | 4 +- site/docs/migration-from-monocle.md | 14 +- site/docs/multifocus.md | 28 ++-- site/docs/optics.md | 98 ++++++------ .../eo/CompositionMatrixSpec.scala | 118 +++++++------- .../constructive/eo/EoSpecificLawsSpec.scala | 36 ++--- .../constructive/eo/OpticsBehaviorSpec.scala | 122 +++++++------- .../dev/constructive/eo/OpticsLawsSpec.scala | 44 +++--- .../dev/constructive/eo/PlatedSpec.scala | 2 +- .../scala/dev/constructive/eo/Samples.scala | 2 +- 43 files changed, 596 insertions(+), 592 deletions(-) create mode 100644 core/src/main/scala/dev/constructive/eo/data/ModifyF.scala delete mode 100644 core/src/main/scala/dev/constructive/eo/data/SetterF.scala create mode 100644 core/src/main/scala/dev/constructive/eo/optics/Modify.scala delete mode 100644 core/src/main/scala/dev/constructive/eo/optics/Setter.scala create mode 100644 laws/src/main/scala/dev/constructive/eo/laws/ModifyLaws.scala delete mode 100644 laws/src/main/scala/dev/constructive/eo/laws/SetterLaws.scala rename laws/src/main/scala/dev/constructive/eo/laws/data/{SetterFLaws.scala => ModifyFLaws.scala} (63%) rename laws/src/main/scala/dev/constructive/eo/laws/data/discipline/{SetterFTests.scala => ModifyFTests.scala} (70%) rename laws/src/main/scala/dev/constructive/eo/laws/discipline/{SetterTests.scala => ModifyTests.scala} (70%) diff --git a/README.md b/README.md index 36edaaa3..ac17b6a7 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,6 +76,9 @@ 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. +- [`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/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) — 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 dc49e661..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 @@ -98,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/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 e9f78b0b..0dd23225 100644 --- a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala +++ b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala @@ -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 ff7d3e05..0f9c6a54 100644 --- a/core/src/main/scala/dev/constructive/eo/optics/Fold.scala +++ b/core/src/main/scala/dev/constructive/eo/optics/Fold.scala @@ -39,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/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 defb3513..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 / Unfold 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. */ @@ -263,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/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/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/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/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/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/site/docs/avro.md b/site/docs/avro.md index 27992ab1..ecd72349 100644 --- a/site/docs/avro.md +++ b/site/docs/avro.md @@ -573,7 +573,7 @@ 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(...)` diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index 0da4396f..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 (`Getter` / `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 9082d719..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 @@ -64,7 +64,7 @@ this optic have?" | `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]` — an `F`-layer with no leftover | `Fold` (read-only, `F: Foldable`), `Unfold` (build-only, `embed: F[B] => T`) | -| `SetterF` | `(Fst[X], Snd[X] => A)` | `Setter` | +| `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. @@ -199,18 +201,18 @@ flowchart LR Direct --> MFocus["MultiFocus[F]"] Direct --> ForgetF["Forget[F]"] Tuple2 --> Affine - Tuple2 --> SetterF + Tuple2 --> ModifyF Tuple2 --> MFocus Either --> Affine Either --> MFocus Affine --> MFocus ForgetF --> MFocus - MFocus --> SetterF + 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 ``` diff --git a/site/docs/extensibility.md b/site/docs/extensibility.md index e3e12dd4..6c62b316 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 diff --git a/site/docs/generics.md b/site/docs/generics.md index a5218b32..989b2302 100644 --- a/site/docs/generics.md +++ b/site/docs/generics.md @@ -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/migration-from-monocle.md b/site/docs/migration-from-monocle.md index c89cb6c9..aba50124 100644 --- a/site/docs/migration-from-monocle.md +++ b/site/docs/migration-from-monocle.md @@ -16,11 +16,11 @@ plus a note on where EO diverges. | `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 => …)` | +| `Setter[S, A](f => s => …)` | `Modify[S, S, A, A](f => s => …)` | | *(no equivalent — build-only optics are not standalone citizens in Monocle)* | `Review[S, A](build)` (build-only, one focus) and `Unfold[T, B, F]` (build-only, many: `embed: F[B] => T` — recursion-scheme algebras, aggregation); see [Optics → Review](optics.md#review) / [Unfold](optics.md#unfold) | | `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) | +| `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 (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]` | @@ -80,15 +80,15 @@ 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. -### Getter / Setter compose by collapse, not by carrier +### Getter / Modify compose by collapse, not by carrier -Getter's `T = Unit` and Setter's `SetterF` carrier share no +Getter's `T = Unit` and Modify's `ModifyF` carrier share no `AssociativeFunctor` instance — instead, a chain that touches a read-only optic anywhere collapses to the read-only join (`lens.andThen(getter)` → Getter, `prism.andThen(getter)` → AffineFold, `traversal.andThen(getter)` → Fold), and a chain into -a Setter collapses the read side (`lens.andThen(setter)` → -Setter). `getter.andThen(setter)` itself is void by design — there +a Modify collapses the read side (`lens.andThen(modify)` → +Modify). `getter.andThen(modify)` itself is void by design — there is nothing to write through. See the [composition matrix](optics.md#composition-matrix) for every pair. @@ -121,7 +121,7 @@ degenerate spine (see the [benchmarks](benchmarks.md)). cats-eo's 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)` +exposed as a composable `Modify` — `everywhere.andThen(prism).modify(f)` applies an ordinary optic at every depth — which Monocle has no equivalent for. See the [cookbook recipe](cookbook.md). diff --git a/site/docs/multifocus.md b/site/docs/multifocus.md index b22cd912..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,7 +366,7 @@ 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). diff --git a/site/docs/optics.md b/site/docs/optics.md index 6f3e6a65..6bda7000 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -34,14 +34,14 @@ flowchart TD Review --> Unfold["Unfold[F]"] end - Setter["Setter — write-only"] + Modify["Modify — write-only"] Iso --> Getter Lens --> Getter Prism --> AffineFold Affine --> AffineFold MultiFocus --> Fold - MultiFocus --> Setter + MultiFocus --> Modify Iso --> Review Prism --> Review @@ -51,7 +51,7 @@ flowchart TD click Affine "#affine" click MultiFocus "#multifocus" click Getter "#getter" - click Setter "#setter" + click Modify "#modify" click Review "#review" click Unfold "#unfold" click AffineFold "#affinefold" @@ -63,13 +63,13 @@ 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`. + meet: `Lens ∘ Prism` → `Affine`; `Iso ∘ Modify` → `Modify`. - **The bi-directional spine** (Iso, Lens, Prism, Affine, MultiFocus) carries both a read and a write side. **One-way optics** keep only one: the read-only rung (Getter → AffineFold → Fold, ordered by how many foci a read can produce: exactly one, 0-or-1, many), the build-only rung (Review → Unfold, one focus vs. an `F`-layer of - parts), and write-only Setter. + parts), and write-only Modify. - Composing **into a read-only inner** (or from a read-only outer) drops every write side and lands on the read-only rung at the join of the two read strengths — `lens ∘ getter` → Getter, @@ -78,8 +78,8 @@ How to read it: reversible outers (Iso / Prism / Review — anything whose carrier has a `ReverseAccessor`) compose into Review and Unfold; `review ∘ unfold` and `unfold ∘ review` land on Unfold. -- Composing with a write-only Setter collapses the read side: - `lens ∘ setter` → Setter. +- Composing with a write-only Modify collapses the read side: + `lens ∘ modify` → Modify. `Affine` is the carrier shared by `Optional` (read and write) and `AffineFold` (read-only). `MultiFocus[F]` is the multi-focus carrier; @@ -97,23 +97,23 @@ 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 | Setter | Review | Unfold | +| outer ∘ inner | Iso | Lens | Prism | Optional | Traversal | Getter | AffineFold | Fold | Modify | Review | Unfold | |---------------|-----|------|-------|----------|-----------|--------|------------|------|--------|--------|--------| -| **Iso** | Iso | Lens | Prism | Optional | Traversal | Getter | AffineFold | Fold | Setter | Review | Unfold | -| **Lens** | Lens | Lens | Optional | Optional | Traversal | Getter | AffineFold | Fold | Setter | ∅ | ∅ | -| **Prism** | Prism | Optional | Prism | Optional | Traversal | AffineFold | AffineFold | Fold | Setter | Review | Unfold | -| **Optional** | Optional | Optional | Optional | Optional | Traversal | AffineFold | AffineFold | Fold | Setter | ∅ | ∅ | -| **Traversal** | Traversal | Traversal | Traversal | Traversal | Traversal | Fold | Fold | Fold | Setter | ∅ | ∅ | +| **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 | ∅ | ∅ | ∅ | -| **Setter** | Setter | Setter | Setter | Setter | Setter | ∅ | ∅ | ∅ | Setter | ∅ | ∅ | +| **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 **Setter column/row corner**: a Setter exposes no focus to read +- 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 @@ -184,7 +184,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. @@ -277,10 +277,10 @@ The same join fires with the read-only optic on the *outside* 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 `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 +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) @@ -322,7 +322,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 @@ -416,42 +416,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) @@ -461,16 +461,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): @@ -493,8 +493,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 @@ -509,7 +509,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 @@ -765,17 +765,17 @@ 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. -**`SetterF` outbound** — Setter is a write-side terminal: there is no -outbound `Composer[SetterF, _]`, so a chain that reaches Setter cannot +**`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`). diff --git a/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala b/tests/src/test/scala/dev/constructive/eo/CompositionMatrixSpec.scala index 58ff4a9c..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,7 +64,7 @@ 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)) @@ -117,10 +117,10 @@ 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" >> { @@ -178,10 +178,10 @@ 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" >> { @@ -237,10 +237,10 @@ 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" >> { @@ -298,10 +298,10 @@ 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" >> { @@ -361,10 +361,10 @@ 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" >> { @@ -412,8 +412,8 @@ 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 @@ -460,8 +460,8 @@ 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 @@ -520,8 +520,8 @@ 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 @@ -531,57 +531,57 @@ class CompositionMatrixSpec extends Specification: } } - "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 } - "setter ∘ unfold must not compile" >> { - typeChecks("o_setter.andThen(i_unfold)") must beFalse + "modify ∘ unfold must not compile" >> { + typeChecks("o_modify.andThen(i_unfold)") must beFalse } } @@ -612,8 +612,8 @@ 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 @@ -654,8 +654,8 @@ class CompositionMatrixSpec extends Specification: "unfold ∘ fold must not compile" >> { typeChecks("o_unfold.andThen(i_fold)") must beFalse } - "unfold ∘ setter must not compile" >> { - typeChecks("o_unfold.andThen(i_setter)") 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 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 a72d3e98..13c120f3 100644 --- a/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/OpticsLawsSpec.scala @@ -17,11 +17,11 @@ import optics.{ Optic, Optional, Prism, - Setter, + Modify, Traversal, Unfold, } -import data.{Affine, Forget, Direct, MultiFocus, PSVec, SetterF} +import data.{Affine, Forget, Direct, MultiFocus, PSVec, ModifyF} import laws.{ AffineFoldLaws, GetterLaws, @@ -29,7 +29,7 @@ import laws.{ LensLaws, OptionalLaws, PrismLaws, - SetterLaws, + ModifyLaws, UnfoldLaws, } import laws.discipline.{ @@ -39,11 +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 @@ -163,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] ------------------------------ @@ -307,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 ------------------- @@ -345,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. @@ -441,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)) From 166116eb43f166a3e2b0127f5405e914972e7266 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 08:12:10 +0200 Subject: [PATCH 04/12] docs(site): isometric 3-axis family-taxonomy figure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat taxonomy flowchart conflated the family space's three independent axes. Replace the lead diagram with a hand-authored isometric SVG (static/optic-taxonomy-3d.svg) over: x — focus arity one / 0-or-1 / many y — from side contextual write / total build / none z — read side present (top plane) / absent (bottom plane) The geometry surfaces facts the flat graph could not: Lens/Iso and Optional/Prism differ only on the write-vs-build axis (the Modify rename rationale, now visible); the read-only and build-only rails run parallel (Fold ↔ Unfold mirror); the (0-or-1, build) cell collapses into Review (mend is total); the (many, read, total-build) cell is uninhabited; Modify spans the arity axis as the write-only bottom. - Rendered as a pure SVG — no JS, dark mode via an internal prefers-color-scheme stylesheet matching the Helium palette; verified by headless-browser screenshots in both schemes. - Generator script committed at site/tools/gen-taxonomy-svg.py (deterministic geometry; rerun to regenerate after palette or family changes). - The mermaid graph stays, retitled "Composition joins" — edges are about composition joins, which read better in 2D; the matrix table remains the cell-level truth. Co-Authored-By: Claude Fable 5 --- site/docs/optics.md | 47 ++++- .../laika-static/static/optic-taxonomy-3d.svg | 80 +++++++++ site/tools/gen-taxonomy-svg.py | 169 ++++++++++++++++++ 3 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 site/laika-static/static/optic-taxonomy-3d.svg create mode 100644 site/tools/gen-taxonomy-svg.py diff --git a/site/docs/optics.md b/site/docs/optics.md index 6bda7000..7b0b50f2 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -7,12 +7,47 @@ 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. +trait, differing only in the carrier `F[_, _]` — and the family space +is genuinely **three-axis**: + +- **focus arity** — how many foci the read side can produce: + exactly one, 0-or-1, or many; +- **from side** — what the `from` half does: a *contextual write* + (needs the leftover `X` to rebuild — Lens-like), a *total build* + (needs no context — Iso/Review-like), or nothing; +- **read side** — present or absent. + +![The three-axis optic family taxonomy: focus arity × from side × read side](static/optic-taxonomy-3d.svg) + +The geometry carries real information: + +- **Lens vs Iso** (and Optional vs Prism) differ *only* on the + from-side axis — write-with-context vs total build. That distinction + is also why the write-only family is named `Modify`, not "Setter": + it lives in the *write* column, with the build column belonging to + Review and Unfold. +- The **read-only rail** (Getter → AffineFold → Fold) and the + **build-only rail** (Review → Unfold) run parallel along the arity + axis — `Fold` and `Unfold` are mirror images on the same + `Forget[F]` carrier. +- The **(0-or-1, build, no-read) cell collapses into Review**: a + Prism's `mend` is total, so a "partial Review" is just Review. +- The **(many, read, total-build) cell is uninhabited**: nothing + total-builds from many foci while also reading them — recursive + structures get there with *contextual* rebuilds instead (`Plated`'s + `plate` keeps the structural skeleton, so it sits in the Traversal + cell). +- **Modify spans the arity axis**: `(A => B) => S => T` never + observes how many foci the function is applied at, so it is the + carrier-agnostic write-only bottom of the whole family space. + +### Composition joins + +Composition is easier to read in two dimensions: 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 on the read-only rail. ```mermaid flowchart TD 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..7b9fa05a --- /dev/null +++ b/site/laika-static/static/optic-taxonomy-3d.svg @@ -0,0 +1,80 @@ + + + + + + + +Lens + +Optional + +Traversal + +Iso + +Prism + +Getter + +AffineFold + +Fold + +∅ +no many-focus Iso + +Review + +≡ Review +mend is total + +Unfold + +Modify +write-only — arity-agnostic + +focus arity: one → 0-or-1 → many + +from side: write → build → none + +read side +present (top) +absent (bottom) +reads +build / write +only + diff --git a/site/tools/gen-taxonomy-svg.py b/site/tools/gen-taxonomy-svg.py new file mode 100644 index 00000000..2717b11d --- /dev/null +++ b/site/tools/gen-taxonomy-svg.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Generate the isometric 3-axis optic-family taxonomy SVG for site/docs/optics.md. + +Axes: + u (lower-right) : focus arity — one, 0-or-1, many + v (upper-right) : from-side — contextual write, total build, none + vertical (layers): read side — present (top), absent (bottom) +""" + +S = 112 # tile edge length driver +UX, UY = 0.866 * S, 0.5 * S # u: arity direction (lower-right) +VX, VY = 0.866 * S, -0.5 * S # v: from-side direction (upper-right) +LAYER_DY = 330 # vertical gap between the two layer origins +MX, MY = 180, 175 # margins (left margin holds the read-axis label) + +def pt(i, j, dy=0.0): + """Screen position of grid node (i along u/arity, j along v/from-side).""" + return (MX + i * UX + j * VX, MY + i * UY + j * VY + dy) + +def diamond(i, j, dy, cls, dash=False, span=1): + """Iso tile at (i, j); span>1 stretches along u (the Modify bar).""" + p0 = pt(i, j, dy) + p1 = pt(i + span, j, dy) + p2 = pt(i + span, j + 1, dy) + p3 = pt(i, j + 1, 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, span=1): + x0, y0 = pt(i, j, dy) + x1, y1 = pt(i + span, j + 1, dy) + return ((x0 + x1) / 2, (y0 + y1) / 2) + +def label(i, j, dy, name, sub=None, cls="fam", span=1): + cx, cy = center(i, j, dy, span) + out = [] + if sub: + out.append(f'{name}') + out.append(f'{sub}') + else: + out.append(f'{name}') + return '\n'.join(out) + +parts = [] + +# ---------- styles ---------- +parts.append('''''') + +# ---------- drop lines between layers (drawn first, behind tiles) ---------- +for (i, j) in [(0, 1), (3, 1), (0, 2), (3, 2)]: + x0, y0 = pt(i, j, 0) + x1, y1 = pt(i, j, LAYER_DY) + parts.append(f'') + +# ---------- TOP layer: read side present ---------- +top = [ + # (i, j, class, name, sub) + (0, 0, 'rw', 'Lens', None), + (1, 0, 'rw', 'Optional', None), + (2, 0, 'rw', 'Traversal', None), + (0, 1, 'rb', 'Iso', None), + (1, 1, 'rb', 'Prism', None), + (0, 2, 'ro', 'Getter', None), + (1, 2, 'ro', 'AffineFold', None), + (2, 2, 'ro', 'Fold', None), +] +for i, j, cls, name, sub in top: + parts.append(diamond(i, j, 0, cls)) + parts.append(label(i, j, 0, name, sub)) +# uninhabited (many, read, total-build) +parts.append(diamond(2, 1, 0, 'ghost', dash=True)) +parts.append(label(2, 1, 0, '∅', 'no many-focus Iso', cls='ghost-t')) + +# ---------- BOTTOM layer: read side absent ---------- +# build column +parts.append(diamond(0, 1, LAYER_DY, 'bo')) +parts.append(label(0, 1, LAYER_DY, 'Review', None)) +parts.append(diamond(1, 1, LAYER_DY, 'ghost', dash=True)) +parts.append(label(1, 1, LAYER_DY, '≡ Review', 'mend is total', cls='ghost-t')) +parts.append(diamond(2, 1, LAYER_DY, 'bo')) +parts.append(label(2, 1, LAYER_DY, 'Unfold', None)) +# write column: Modify bar spanning all three arities +parts.append(diamond(0, 0, LAYER_DY, 'mod', span=3)) +parts.append(label(0, 0, LAYER_DY, 'Modify', 'write-only — arity-agnostic', span=3)) + +# ---------- axis arrows + labels ---------- +def arrow(x0, y0, x1, y1): + import math + ang = math.atan2(y1 - y0, x1 - x0) + ax, ay = x1, y1 + l = 9 + a1 = (ax - l * math.cos(ang - 0.42), ay - l * math.sin(ang - 0.42)) + a2 = (ax - l * math.cos(ang + 0.42), ay - l * math.sin(ang + 0.42)) + return (f'' + f'') + +# arity axis: along u, drawn below-left of the bottom layer's write column +ax0 = pt(0, -0.42, LAYER_DY) +ax1 = pt(3.05, -0.42, LAYER_DY) +parts.append(arrow(*ax0, *ax1)) +mid = ((ax0[0] + ax1[0]) / 2, (ax0[1] + ax1[1]) / 2) +parts.append(f'focus arity: one → 0-or-1 → many') + +# from-side axis: along v, drawn upper-left of the top layer +fx0 = pt(-0.42, 0, 0) +fx1 = pt(-0.42, 3.05, 0) +parts.append(arrow(*fx0, *fx1)) +fmid = ((fx0[0] + fx1[0]) / 2, (fx0[1] + fx1[1]) / 2) +parts.append(f'from side: write → build → none') + +# read axis: vertical, on the far left +rx = MX - 118 +ry0, ry1 = MY + 60, MY + LAYER_DY + 10 +parts.append(arrow(rx, ry0, rx, ry1)) +parts.append(f'read side') +parts.append(f'present (top)') +parts.append(f'absent (bottom)') + +# layer captions on the right +cap_x = pt(3.3, 1.35, 0) +parts.append(f'reads') +cap_xb = pt(3.3, 1.35, LAYER_DY) +parts.append(f'build / write') +parts.append(f'only') + +W = int(MX + 3 * UX + 3 * VX + 120) +H = int(MY + 3 * UY + LAYER_DY + 95) +svg = (f'\n' + '\n'.join(parts) + '\n\n') + +import os +out = os.path.join(os.path.dirname(__file__), '..', 'laika-static', 'static', 'optic-taxonomy-3d.svg') +open(out, 'w').write(svg) +print(f"wrote {out} viewBox 0 0 {W} {H}") From b9c1ca81ba4cd32de76b06618038ed9c52b82f6f Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 08:43:38 +0200 Subject: [PATCH 05/12] =?UTF-8?q?docs(site):=20taxonomy=20figure=20v2=20?= =?UTF-8?q?=E2=80=94=20capability=20layers=20=C3=97=20read/write=20cardina?= =?UTF-8?q?lity;=20drop=20the=20mermaid=20graph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the isometric taxonomy around the composition machinery itself and make it the sole family diagram (the mermaid joins graph is gone — the matrix table is the cell-level truth, the figure the geometric intuition): x — read cardinality 1 / 0-or-1 / N (the ReadCompose join axis) y — write cardinality 1 / 0-or-1 / N (a future WriteCompose) z — capability read-only (top), read-write (middle), write/build-only (bottom) Read-only families collapse to a rail on the read axis (Getter / AffineFold / Fold — the top layer); write/build-only families to a rail on the write axis (Review / Unfold + the cardinality-agnostic Modify bar — the bottom layer); the read-write grid pairs the axes (Iso·Lens at (1,1), Prism·Optional at (0-or-1,1), Traversal at (N,N)). Read-collapse = projection up onto the top layer at the ReadCompose join; write/build-collapse = projection down. The write-cardinality 0-or-1 cells (fallible write / fallible build) are rendered as planned (tan, dashed) and pointed at the failure-typed-build spike; a scope addendum in the BiAffine brainstorm pulls WriteCompose and the missing-cell carriers into that plan explicitly. Verified by headless-browser screenshots (light + dark) of both the standalone SVG and the built optics.html. Co-Authored-By: Claude Fable 5 --- ...2026-06-10-failure-typed-build-biaffine.md | 20 ++ site/docs/optics.md | 142 +++-------- .../laika-static/static/optic-taxonomy-3d.svg | 116 ++++----- site/tools/gen-taxonomy-svg.py | 221 +++++++++--------- 4 files changed, 234 insertions(+), 265 deletions(-) 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..abcdf060 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,23 @@ 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 (read cardinality × write cardinality × capability — +`site/docs/optics.md`, generated by `site/tools/gen-taxonomy-svg.py`) places this spike's +deliverables precisely, and two items are hereby pulled INTO this plan's scope: + +1. **`WriteCompose`** — the write-side join typeclass mirroring `ReadCompose`: composing + write/build sides should land at the join of their write cardinalities (1 / 0-or-1 / N), + the way `ReadCompose` lands read-only chains at the join of read strengths. The fallible + (0-or-1) rung of that lattice is exactly this spike's error channel, so the typeclass and + the carrier should be designed together. +2. **Missing-cell carriers** — the taxonomy's empty cells are write-cardinality gaps: + - middle-layer (read-write) write-0-or-1 column: *fallible writes* (smart-constructor + replace, per-element encode failure) — Candidates B/C; + - bottom-layer (build-only) 0-or-1 cell: *fallible build* — the `Either[E, T]`-shaped + `from`; + - the off-diagonal ∅ cells (read-N × write-1, read-1 × write-N, read-0/1 × write-N) need + carriers whose write cardinality differs from their read cardinality — candidates should + fall out of the same carrier generalization rather than be designed ad hoc. diff --git a/site/docs/optics.md b/site/docs/optics.md index 7b0b50f2..4f164d1f 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -8,113 +8,47 @@ Scaladoc. Every family is a specialisation of the same `Optic[S, T, A, B, F]` trait, differing only in the carrier `F[_, _]` — and the family space -is genuinely **three-axis**: - -- **focus arity** — how many foci the read side can produce: - exactly one, 0-or-1, or many; -- **from side** — what the `from` half does: a *contextual write* - (needs the leftover `X` to rebuild — Lens-like), a *total build* - (needs no context — Iso/Review-like), or nothing; -- **read side** — present or absent. - -![The three-axis optic family taxonomy: focus arity × from side × read side](static/optic-taxonomy-3d.svg) - -The geometry carries real information: - -- **Lens vs Iso** (and Optional vs Prism) differ *only* on the - from-side axis — write-with-context vs total build. That distinction - is also why the write-only family is named `Modify`, not "Setter": - it lives in the *write* column, with the build column belonging to - Review and Unfold. -- The **read-only rail** (Getter → AffineFold → Fold) and the - **build-only rail** (Review → Unfold) run parallel along the arity - axis — `Fold` and `Unfold` are mirror images on the same - `Forget[F]` carrier. -- The **(0-or-1, build, no-read) cell collapses into Review**: a - Prism's `mend` is total, so a "partial Review" is just Review. -- The **(many, read, total-build) cell is uninhabited**: nothing - total-builds from many foci while also reading them — recursive - structures get there with *contextual* rebuilds instead (`Plated`'s - `plate` keeps the structural skeleton, so it sits in the Traversal - cell). -- **Modify spans the arity axis**: `(A => B) => S => T` never - observes how many foci the function is applied at, so it is the - carrier-agnostic write-only bottom of the whole family space. - -### Composition joins - -Composition is easier to read in two dimensions: 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 on the read-only rail. - -```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 readonly["Read-only"] - Getter --> AffineFold - AffineFold --> Fold - end - - subgraph buildonly["Build-only"] - Review --> Unfold["Unfold[F]"] - end - - Modify["Modify — write-only"] - - Iso --> Getter - Lens --> Getter - Prism --> AffineFold - Affine --> AffineFold - MultiFocus --> Fold - MultiFocus --> Modify - Iso --> Review - Prism --> Review - - click Iso "#iso" - click Lens "#lens" - click Prism "#prism" - click Affine "#affine" - click MultiFocus "#multifocus" - click Getter "#getter" - click Modify "#modify" - click Review "#review" - click Unfold "#unfold" - click AffineFold "#affinefold" - click Fold "#fold" -``` +is **three-axis**: + +- **read cardinality** — how many foci the read side produces: + 1, 0-or-1, or N. This is the axis `ReadCompose` joins along. +- **write cardinality** — how many foci the write/build side + consumes: 1, 0-or-1, or N. Its join (a `WriteCompose` mirroring + `ReadCompose`) and its 0-or-1 column — *fallible* writes and builds + — are not shipped yet: they are 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: read cardinality × write cardinality × 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 ∘ Modify` → `Modify`. -- **The bi-directional spine** (Iso, Lens, Prism, Affine, MultiFocus) - carries both a read and a write side. **One-way optics** keep only - one: the read-only rung (Getter → AffineFold → Fold, ordered by how - many foci a read can produce: exactly one, 0-or-1, many), the - build-only rung (Review → Unfold, one focus vs. an `F`-layer of - parts), and write-only Modify. -- Composing **into a read-only inner** (or from a read-only outer) - drops every write side and lands on the read-only rung at the join - of the two read strengths — `lens ∘ getter` → Getter, - `prism ∘ getter` → AffineFold, `traversal ∘ getter` → Fold. -- Composing **through the build side** keeps only build halves: - reversible outers (Iso / Prism / Review — anything whose carrier has - a `ReverseAccessor`) compose into Review and Unfold; - `review ∘ unfold` and `unfold ∘ review` land on Unfold. -- Composing with a write-only Modify collapses the read side: - `lens ∘ modify` → Modify. +- The **top layer** is read-only — no write side, so it collapses to + the read axis: Getter (1), AffineFold (0-or-1), Fold (N). Any chain + that touches a read-only optic projects **up** onto this layer, + landing at the join of the read cardinalities — `lens ∘ getter` → + Getter, `prism ∘ getter` → AffineFold, `traversal ∘ getter` → Fold. + That join is exactly what `ReadCompose` computes. +- The **middle layer** pairs the two axes. `Iso · Lens` sit at (1, 1) + (total vs contextual rebuild of the same shape), `Prism · Optional` + at (0-or-1, 1), `Traversal` at (N, N). The off-diagonal cells (∅) + have no shipped carrier. +- The **bottom layer** is write/build-only — no read side, so it + collapses to the write axis: Review builds 1, Unfold builds from N + (`embed: F[B] => T` — Fold's mirror on the same `Forget[F]` + carrier). Build-side composition projects **down** onto this layer + (`iso ∘ review` → Review, `review ∘ unfold` → Unfold). `Modify` + spans the axis: `(A => B) => S => T` never observes how many foci + the function lands on, so it is the cardinality-agnostic write-only + bottom — `lens ∘ modify` → Modify is the corresponding downward + projection. +- The **tan cells are planned, not missing by accident**: write + cardinality 0-or-1 means a write/build that can miss or reject — + smart-constructor writes, per-element encode failures. Those cells, + the `WriteCompose` join, and carriers for the remaining ∅ cells 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; diff --git a/site/laika-static/static/optic-taxonomy-3d.svg b/site/laika-static/static/optic-taxonomy-3d.svg index 7b9fa05a..a1f13877 100644 --- a/site/laika-static/static/optic-taxonomy-3d.svg +++ b/site/laika-static/static/optic-taxonomy-3d.svg @@ -1,19 +1,21 @@ - + - - - - - -Lens - -Optional - -Traversal - -Iso - -Prism - -Getter - -AffineFold - -Fold - -∅ -no many-focus Iso - -Review - -≡ Review -mend is total - -Unfold - -Modify -write-only — arity-agnostic - -focus arity: one → 0-or-1 → many - -from side: write → build → none - -read side -present (top) -absent (bottom) -reads -build / write -only + + + +Getter + +AffineFold + +Fold + +Iso · Lens +total · contextual + +Prism · Optional +total · contextual + +∅ + +fallible write +BiAffine — planned + +∅ + +∅ + +Traversal + +Review + +fallible build +BiAffine — planned + +Unfold + +Modify +cardinality-agnostic + +read cardinality (ReadCompose): 1 → 0-or-1 → N + +write cardinality (WriteCompose — planned): 1 → 0-or-1 → N + +capability +read-only (top) +write-only (bottom) +read-only +no write side — collapses to the read axis +read-write +write / build only +no read side — collapses to the write axis diff --git a/site/tools/gen-taxonomy-svg.py b/site/tools/gen-taxonomy-svg.py index 2717b11d..14670edd 100644 --- a/site/tools/gen-taxonomy-svg.py +++ b/site/tools/gen-taxonomy-svg.py @@ -2,64 +2,67 @@ """Generate the isometric 3-axis optic-family taxonomy SVG for site/docs/optics.md. Axes: - u (lower-right) : focus arity — one, 0-or-1, many - v (upper-right) : from-side — contextual write, total build, none - vertical (layers): read side — present (top), absent (bottom) + u (lower-right) : READ cardinality — 1, 0-or-1, N (the ReadCompose join lattice) + v (upper-right) : WRITE cardinality — 1, 0-or-1, N (a future WriteCompose; see the + failure-typed-build / BiAffine plan) + vertical (layers): capability — read-only (top), read-write (middle), + write/build-only (bottom) + +Read-only families have no write side, so the top layer is a rail along u; +write-only families have no read side, so the bottom layer is a rail along v +(plus the cardinality-agnostic Modify bar). The middle layer is the full grid. """ +import math, os -S = 112 # tile edge length driver -UX, UY = 0.866 * S, 0.5 * S # u: arity direction (lower-right) -VX, VY = 0.866 * S, -0.5 * S # v: from-side direction (upper-right) -LAYER_DY = 330 # vertical gap between the two layer origins -MX, MY = 180, 175 # margins (left margin holds the read-axis label) +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): - """Screen position of grid node (i along u/arity, j along v/from-side).""" return (MX + i * UX + j * VX, MY + i * UY + j * VY + dy) -def diamond(i, j, dy, cls, dash=False, span=1): - """Iso tile at (i, j); span>1 stretches along u (the Modify bar).""" - p0 = pt(i, j, dy) - p1 = pt(i + span, j, dy) - p2 = pt(i + span, j + 1, dy) - p3 = pt(i, j + 1, 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, span=1): +def center(i, j, dy, uspan=1, vspan=1): x0, y0 = pt(i, j, dy) - x1, y1 = pt(i + span, j + 1, 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", span=1): - cx, cy = center(i, j, dy, span) - out = [] +def label(i, j, dy, name, sub=None, cls="fam", uspan=1, vspan=1): + cx, cy = center(i, j, dy, uspan, vspan) if sub: - out.append(f'{name}') - out.append(f'{sub}') - else: - out.append(f'{name}') - return '\n'.join(out) + return (f'{name}\n' + f'{sub}') + return f'{name}' parts = [] -# ---------- styles ---------- parts.append('''''') -# ---------- drop lines between layers (drawn first, behind tiles) ---------- -for (i, j) in [(0, 1), (3, 1), (0, 2), (3, 2)]: - x0, y0 = pt(i, j, 0) - x1, y1 = pt(i, j, LAYER_DY) +# ---------- 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 side present ---------- -top = [ - # (i, j, class, name, sub) - (0, 0, 'rw', 'Lens', None), - (1, 0, 'rw', 'Optional', None), - (2, 0, 'rw', 'Traversal', None), - (0, 1, 'rb', 'Iso', None), - (1, 1, 'rb', 'Prism', None), - (0, 2, 'ro', 'Getter', None), - (1, 2, 'ro', 'AffineFold', None), - (2, 2, 'ro', 'Fold', None), -] -for i, j, cls, name, sub in top: - parts.append(diamond(i, j, 0, cls)) - parts.append(label(i, j, 0, name, sub)) -# uninhabited (many, read, total-build) -parts.append(diamond(2, 1, 0, 'ghost', dash=True)) -parts.append(label(2, 1, 0, '∅', 'no many-focus Iso', cls='ghost-t')) - -# ---------- BOTTOM layer: read side absent ---------- -# build column -parts.append(diamond(0, 1, LAYER_DY, 'bo')) -parts.append(label(0, 1, LAYER_DY, 'Review', None)) -parts.append(diamond(1, 1, LAYER_DY, 'ghost', dash=True)) -parts.append(label(1, 1, LAYER_DY, '≡ Review', 'mend is total', cls='ghost-t')) -parts.append(diamond(2, 1, LAYER_DY, 'bo')) -parts.append(label(2, 1, LAYER_DY, 'Unfold', None)) -# write column: Modify bar spanning all three arities -parts.append(diamond(0, 0, LAYER_DY, 'mod', span=3)) -parts.append(label(0, 0, LAYER_DY, 'Modify', 'write-only — arity-agnostic', span=3)) +# ---------- TOP layer: read-only (rail along u; no write side, v collapses) ---------- +JR = 1.0 # rail sits on the middle v-row so it hovers over the grid's centre +for i, name in enumerate(['Getter', 'AffineFold', 'Fold']): + parts.append(tile(i, JR, TOP, 'ro')) + parts.append(label(i, JR, TOP, name)) + +# ---------- MIDDLE layer: read-write (full grid: u = read card, v = write card) ---------- +# v index: 0 → write 1, 1 → write 0-or-1, 2 → write N +parts.append(tile(0, 0, MID, 'rw')) +parts.append(label(0, 0, MID, 'Iso · Lens', 'total · contextual')) +parts.append(tile(1, 0, MID, 'rw')) +parts.append(label(1, 0, MID, 'Prism · Optional', 'total · contextual')) +parts.append(tile(2, 0, MID, 'empty', dash=True)) +parts.append(label(2, 0, MID, '∅', cls='empty-t')) +# fallible-write column (write cardinality 0-or-1) — BiAffine territory +parts.append(tile(0, 1, MID, 'plan', dash=True, uspan=3)) +parts.append(label(0, 1, MID, 'fallible write', 'BiAffine — planned', cls='plan-t', uspan=3)) +parts.append(tile(0, 2, MID, 'empty', dash=True)) +parts.append(label(0, 2, MID, '∅', cls='empty-t')) +parts.append(tile(1, 2, MID, 'empty', dash=True)) +parts.append(label(1, 2, MID, '∅', cls='empty-t')) +parts.append(tile(2, 2, MID, 'rw')) +parts.append(label(2, 2, MID, 'Traversal', None)) + +# ---------- BOTTOM layer: write/build-only (rail along v; no read side, u collapses) ---------- +IR = 1.0 # rail sits on the middle u-row +parts.append(tile(IR, 0, BOT, 'bo')) +parts.append(label(IR, 0, BOT, 'Review', None)) +parts.append(tile(IR, 1, BOT, 'plan', dash=True)) +parts.append(label(IR, 1, BOT, 'fallible build', 'BiAffine — planned', cls='plan-t')) +parts.append(tile(IR, 2, BOT, 'bo')) +parts.append(label(IR, 2, BOT, 'Unfold', None)) +# Modify: write-only and cardinality-agnostic — a bar spanning the write axis +parts.append(tile(IR - 1.3, -0.25, BOT, 'mod', vspan=3)) +parts.append(label(IR - 1.3, -0.25, BOT, 'Modify', 'cardinality-agnostic', vspan=3)) # ---------- axis arrows + labels ---------- def arrow(x0, y0, x1, y1): - import math ang = math.atan2(y1 - y0, x1 - x0) - ax, ay = x1, y1 l = 9 - a1 = (ax - l * math.cos(ang - 0.42), ay - l * math.sin(ang - 0.42)) - a2 = (ax - l * math.cos(ang + 0.42), ay - l * math.sin(ang + 0.42)) - return (f'' - f'') - -# arity axis: along u, drawn below-left of the bottom layer's write column -ax0 = pt(0, -0.42, LAYER_DY) -ax1 = pt(3.05, -0.42, LAYER_DY) -parts.append(arrow(*ax0, *ax1)) -mid = ((ax0[0] + ax1[0]) / 2, (ax0[1] + ax1[1]) / 2) -parts.append(f'focus arity: one → 0-or-1 → many') - -# from-side axis: along v, drawn upper-left of the top layer -fx0 = pt(-0.42, 0, 0) -fx1 = pt(-0.42, 3.05, 0) -parts.append(arrow(*fx0, *fx1)) -fmid = ((fx0[0] + fx1[0]) / 2, (fx0[1] + fx1[1]) / 2) -parts.append(f'from side: write → build → none') - -# read axis: vertical, on the far left -rx = MX - 118 -ry0, ry1 = MY + 60, MY + LAYER_DY + 10 + 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'read cardinality (ReadCompose): 1 → 0-or-1 → N') + +# 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'write cardinality (WriteCompose — planned): 1 → 0-or-1 → N') + +# 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'read side') -parts.append(f'present (top)') -parts.append(f'absent (bottom)') +parts.append(f'capability') +parts.append(f'read-only (top)') +parts.append(f'write-only (bottom)') # layer captions on the right -cap_x = pt(3.3, 1.35, 0) -parts.append(f'reads') -cap_xb = pt(3.3, 1.35, LAYER_DY) -parts.append(f'build / write') -parts.append(f'only') - -W = int(MX + 3 * UX + 3 * VX + 120) -H = int(MY + 3 * UY + LAYER_DY + 95) +for dy, name, sub in [(TOP, 'read-only', 'no write side — collapses to the read axis'), + (MID, 'read-write', None), + (BOT, 'write / build only', 'no read side — collapses to the write axis')]: + 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') + f'read cardinality by write cardinality by capability layer">\n' + '\n'.join(parts) + '\n\n') -import os out = os.path.join(os.path.dirname(__file__), '..', 'laika-static', 'static', 'optic-taxonomy-3d.svg') open(out, 'w').write(svg) -print(f"wrote {out} viewBox 0 0 {W} {H}") +print(f"wrote {os.path.normpath(out)} viewBox 0 0 {W} {H}") From e265d7b3fd93ba6737a63846c75fa8a075bcc2e6 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 09:24:17 +0200 Subject: [PATCH 06/12] =?UTF-8?q?docs(site):=20taxonomy=20axes=20v3=20?= =?UTF-8?q?=E2=80=94=20focus=20nature=20=C3=97=20source=20nature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the cardinality axes with the sharper pair (user-designed): x — focus nature total / fallible / multiple (how `to` lands; exactly the ReadCompose join lattice) y — source nature total / contextual / fallible (what `from` needs: bijection-or-mend / leftover X / can reject — the WriteCompose / BiAffine rung) z — capability read-only / read-write / write-only (unchanged) The payoff over the cardinality axes: the read-write grid becomes COMPLETELY inhabited — Iso (total,total), Lens (total,contextual), Prism (fallible,total), Optional (fallible,contextual), Traversal spanning (multiple × {total,contextual}) since fixed-shape/Grate rebuilds totally via tabulate while `each` rebuilds contextually — and the only remaining row is the planned fallible-source one (fallible write / BiAffine / fallible each), which maps one-to-one onto the failure-typed-build spike's candidates. Iso/Lens and Prism/Optional get their own cells back, and both one-way rails now run parallel along the focus axis (Review consumes a total focus, Unfold a multiple one). BiAffine brainstorm addendum updated to the new axes: with this basis, every remaining hole in the family space IS that spike. Co-Authored-By: Claude Fable 5 --- ...2026-06-10-failure-typed-build-biaffine.md | 35 ++++---- site/docs/optics.md | 68 +++++++++------- .../laika-static/static/optic-taxonomy-3d.svg | 54 +++++++------ site/tools/gen-taxonomy-svg.py | 79 ++++++++++--------- 4 files changed, 129 insertions(+), 107 deletions(-) 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 abcdf060..e4c259ad 100644 --- a/docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md +++ b/docs/brainstorms/2026-06-10-failure-typed-build-biaffine.md @@ -123,20 +123,25 @@ 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 (read cardinality × write cardinality × capability — +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, and two items are hereby pulled INTO this plan's scope: - -1. **`WriteCompose`** — the write-side join typeclass mirroring `ReadCompose`: composing - write/build sides should land at the join of their write cardinalities (1 / 0-or-1 / N), - the way `ReadCompose` lands read-only chains at the join of read strengths. The fallible - (0-or-1) rung of that lattice is exactly this spike's error channel, so the typeclass and +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. **Missing-cell carriers** — the taxonomy's empty cells are write-cardinality gaps: - - middle-layer (read-write) write-0-or-1 column: *fallible writes* (smart-constructor - replace, per-element encode failure) — Candidates B/C; - - bottom-layer (build-only) 0-or-1 cell: *fallible build* — the `Either[E, T]`-shaped - `from`; - - the off-diagonal ∅ cells (read-N × write-1, read-1 × write-N, read-0/1 × write-N) need - carriers whose write cardinality differs from their read cardinality — candidates should - fall out of the same carrier generalization rather than be designed ad hoc. +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/site/docs/optics.md b/site/docs/optics.md index 4f164d1f..d02e923b 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -10,44 +10,54 @@ Every family is a specialisation of the same `Optic[S, T, A, B, F]` trait, differing only in the carrier `F[_, _]` — and the family space is **three-axis**: -- **read cardinality** — how many foci the read side produces: - 1, 0-or-1, or N. This is the axis `ReadCompose` joins along. -- **write cardinality** — how many foci the write/build side - consumes: 1, 0-or-1, or N. Its join (a `WriteCompose` mirroring - `ReadCompose`) and its 0-or-1 column — *fallible* writes and builds - — are not shipped yet: they are the territory of the +- **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: read cardinality × write cardinality × capability](static/optic-taxonomy-3d.svg) +![The three-axis optic family taxonomy: focus nature × source nature × capability](static/optic-taxonomy-3d.svg) How to read it: -- The **top layer** is read-only — no write side, so it collapses to - the read axis: Getter (1), AffineFold (0-or-1), Fold (N). Any chain - that touches a read-only optic projects **up** onto this layer, - landing at the join of the read cardinalities — `lens ∘ getter` → +- The **top layer** is read-only — no `from`, so it collapses to the + focus axis: Getter (total), AffineFold (fallible), Fold (multiple). + 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** pairs the two axes. `Iso · Lens` sit at (1, 1) - (total vs contextual rebuild of the same shape), `Prism · Optional` - at (0-or-1, 1), `Traversal` at (N, N). The off-diagonal cells (∅) - have no shipped carrier. -- The **bottom layer** is write/build-only — no read side, so it - collapses to the write axis: Review builds 1, Unfold builds from N - (`embed: F[B] => T` — Fold's mirror on the same `Forget[F]` - carrier). Build-side composition projects **down** onto this layer - (`iso ∘ review` → Review, `review ∘ unfold` → Unfold). `Modify` - spans the axis: `(A => B) => S => T` never observes how many foci - the function lands on, so it is the cardinality-agnostic write-only - bottom — `lens ∘ modify` → Modify is the corresponding downward - projection. -- The **tan cells are planned, not missing by accident**: write - cardinality 0-or-1 means a write/build that can miss or reject — - smart-constructor writes, per-element encode failures. Those cells, - the `WriteCompose` join, and carriers for the remaining ∅ cells are - scoped to the failure-typed-build spike +- 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 — no read side — and rails + along the focus axis in parallel with the top layer: Review consumes + a total focus, Unfold a multiple one (`embed: F[B] => T` — Fold's + mirror on the same `Forget[F]` carrier). Build-side composition + projects **down** onto this layer (`iso ∘ review` → Review, + `review ∘ unfold` → Unfold). `Modify` spans the axis: + `(A => B) => S => T` never observes the focus shape it lands on, so + it is the focus-agnostic write-only bottom — `lens ∘ modify` → + Modify is the corresponding downward projection. +- 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 diff --git a/site/laika-static/static/optic-taxonomy-3d.svg b/site/laika-static/static/optic-taxonomy-3d.svg index a1f13877..657356ef 100644 --- a/site/laika-static/static/optic-taxonomy-3d.svg +++ b/site/laika-static/static/optic-taxonomy-3d.svg @@ -46,36 +46,40 @@ Fold -Iso · Lens -total · contextual +Iso + +Lens -Prism · Optional -total · contextual - -∅ - -fallible write -BiAffine — planned - -∅ - -∅ - -Traversal - -Review +Prism + +Optional +Affine carrier + +Traversal +fixed/Grate: total · each: contextual + +fallible write +planned + +BiAffine +planned + +fallible each +planned + +Review fallible build -BiAffine — planned - -Unfold - -Modify -cardinality-agnostic +planned + +Unfold + +Modify +focus-agnostic -read cardinality (ReadCompose): 1 → 0-or-1 → N +focus nature (ReadCompose): total → fallible → multiple -write cardinality (WriteCompose — planned): 1 → 0-or-1 → N +source nature: total → contextual → fallible (WriteCompose — planned) capability read-only (top) diff --git a/site/tools/gen-taxonomy-svg.py b/site/tools/gen-taxonomy-svg.py index 14670edd..c87f99f6 100644 --- a/site/tools/gen-taxonomy-svg.py +++ b/site/tools/gen-taxonomy-svg.py @@ -2,15 +2,16 @@ """Generate the isometric 3-axis optic-family taxonomy SVG for site/docs/optics.md. Axes: - u (lower-right) : READ cardinality — 1, 0-or-1, N (the ReadCompose join lattice) - v (upper-right) : WRITE cardinality — 1, 0-or-1, N (a future WriteCompose; see the - failure-typed-build / BiAffine plan) - vertical (layers): capability — read-only (top), read-write (middle), - write/build-only (bottom) - -Read-only families have no write side, so the top layer is a rail along u; -write-only families have no read side, so the bottom layer is a rail along v -(plus the cardinality-agnostic Modify bar). The middle layer is the full grid. + 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) + +One-way layers collapse to the focus axis (read-only has no `from`; write-only +rails by the focus shape its build consumes), so both rails run parallel along u. +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 @@ -97,35 +98,37 @@ def label(i, j, dy, name, sub=None, cls="fam", uspan=1, vspan=1): parts.append(tile(i, JR, TOP, 'ro')) parts.append(label(i, JR, TOP, name)) -# ---------- MIDDLE layer: read-write (full grid: u = read card, v = write card) ---------- -# v index: 0 → write 1, 1 → write 0-or-1, 2 → write N +# ---------- 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 · Lens', 'total · contextual')) +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 · Optional', 'total · contextual')) -parts.append(tile(2, 0, MID, 'empty', dash=True)) -parts.append(label(2, 0, MID, '∅', cls='empty-t')) -# fallible-write column (write cardinality 0-or-1) — BiAffine territory -parts.append(tile(0, 1, MID, 'plan', dash=True, uspan=3)) -parts.append(label(0, 1, MID, 'fallible write', 'BiAffine — planned', cls='plan-t', uspan=3)) -parts.append(tile(0, 2, MID, 'empty', dash=True)) -parts.append(label(0, 2, MID, '∅', cls='empty-t')) -parts.append(tile(1, 2, MID, 'empty', dash=True)) -parts.append(label(1, 2, MID, '∅', cls='empty-t')) -parts.append(tile(2, 2, MID, 'rw')) -parts.append(label(2, 2, MID, 'Traversal', None)) - -# ---------- BOTTOM layer: write/build-only (rail along v; no read side, u collapses) ---------- -IR = 1.0 # rail sits on the middle u-row -parts.append(tile(IR, 0, BOT, 'bo')) -parts.append(label(IR, 0, BOT, 'Review', None)) -parts.append(tile(IR, 1, BOT, 'plan', dash=True)) -parts.append(label(IR, 1, BOT, 'fallible build', 'BiAffine — planned', cls='plan-t')) -parts.append(tile(IR, 2, BOT, 'bo')) -parts.append(label(IR, 2, BOT, 'Unfold', None)) -# Modify: write-only and cardinality-agnostic — a bar spanning the write axis -parts.append(tile(IR - 1.3, -0.25, BOT, 'mod', vspan=3)) -parts.append(label(IR - 1.3, -0.25, BOT, 'Modify', 'cardinality-agnostic', vspan=3)) +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 (rail along u, mirroring the top rail) ---------- +JB = 1.0 # rail sits on the middle v-row +for i, (name, cls, sub) in enumerate([('Review', 'bo', None), + ('fallible build', 'plan', 'planned'), + ('Unfold', 'bo', None)]): + parts.append(tile(i, JB, BOT, cls, dash=(cls == 'plan'))) + parts.append(label(i, JB, BOT, name, sub, cls=('plan-t' if cls == 'plan' else 'fam'))) +# Modify: write-only and focus-agnostic — a bar spanning the focus axis, in front of the rail +parts.append(tile(0, JB - 1.3, BOT, 'mod', uspan=3)) +parts.append(label(0, JB - 1.3, BOT, 'Modify', 'focus-agnostic', uspan=3)) # ---------- axis arrows + labels ---------- def arrow(x0, y0, x1, y1): @@ -140,13 +143,13 @@ def arrow(x0, y0, x1, y1): 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'read cardinality (ReadCompose): 1 → 0-or-1 → N') +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'write cardinality (WriteCompose — planned): 1 → 0-or-1 → N') +parts.append(f'source nature: total → contextual → fallible (WriteCompose — planned)') # capability axis: vertical, far left rx = MX - 120 From 5857c729a31fc34266c19ed45fb0446c27b0a59e Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 09:36:06 +0200 Subject: [PATCH 07/12] =?UTF-8?q?docs(site):=20taxonomy=20=E2=80=94=20bott?= =?UTF-8?q?om=20layer=20is=20a=20full=20plane,=20not=20a=20rail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A write-only optic's `from` is real (only `to` is vestigial), so both axes apply on the bottom layer, and each family sits directly below the read-write cells whose build half it is: Review below Iso (with ≡ Review below Prism — mend is total), Unfold below Traversal's total flank, Modify as the entire contextual row (it IS the contextual write half of Lens / Optional / each), fallible build as the planned fallible row. The vertical drop now MEANS something: write-only = the layer above minus its read side. Only the read-only top layer remains a rail — its `from` is genuinely absent. Also fixes the previous layout's real error: `fallible build` was railed between Review and Unfold as if it differed in focus nature; it differs in source nature. Co-Authored-By: Claude Fable 5 --- site/docs/optics.md | 23 +++++++++------- .../laika-static/static/optic-taxonomy-3d.svg | 27 ++++++++++--------- site/tools/gen-taxonomy-svg.py | 26 +++++++++--------- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/site/docs/optics.md b/site/docs/optics.md index d02e923b..2a4a1957 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -42,15 +42,20 @@ How to read it: (`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 — no read side — and rails - along the focus axis in parallel with the top layer: Review consumes - a total focus, Unfold a multiple one (`embed: F[B] => T` — Fold's - mirror on the same `Forget[F]` carrier). Build-side composition - projects **down** onto this layer (`iso ∘ review` → Review, - `review ∘ unfold` → Unfold). `Modify` spans the axis: - `(A => B) => S => T` never observes the focus shape it lands on, so - it is the focus-agnostic write-only bottom — `lens ∘ modify` → - Modify is the corresponding downward projection. +- 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 below Iso (total focus, total + source) — and below Prism the cell is *≡ Review* again, because a + `mend` is total; 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 diff --git a/site/laika-static/static/optic-taxonomy-3d.svg b/site/laika-static/static/optic-taxonomy-3d.svg index 657356ef..479b2df2 100644 --- a/site/laika-static/static/optic-taxonomy-3d.svg +++ b/site/laika-static/static/optic-taxonomy-3d.svg @@ -66,16 +66,19 @@ fallible each planned - -Review - -fallible build -planned - -Unfold - -Modify -focus-agnostic + +Review + +≡ Review +a Prism's mend is total + +Unfold + +Modify +the contextual write half — focus-agnostic + +fallible build +planned focus nature (ReadCompose): total → fallible → multiple @@ -85,8 +88,8 @@ read-only (top) write-only (bottom) read-only -no write side — collapses to the read axis +no from at all — collapses to the focus axis read-write write / build only -no read side — collapses to the write axis +the from halves of the layer above diff --git a/site/tools/gen-taxonomy-svg.py b/site/tools/gen-taxonomy-svg.py index c87f99f6..00f3f757 100644 --- a/site/tools/gen-taxonomy-svg.py +++ b/site/tools/gen-taxonomy-svg.py @@ -119,16 +119,18 @@ def label(i, j, dy, name, sub=None, cls="fam", uspan=1, vspan=1): 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 (rail along u, mirroring the top rail) ---------- -JB = 1.0 # rail sits on the middle v-row -for i, (name, cls, sub) in enumerate([('Review', 'bo', None), - ('fallible build', 'plan', 'planned'), - ('Unfold', 'bo', None)]): - parts.append(tile(i, JB, BOT, cls, dash=(cls == 'plan'))) - parts.append(label(i, JB, BOT, name, sub, cls=('plan-t' if cls == 'plan' else 'fam'))) -# Modify: write-only and focus-agnostic — a bar spanning the focus axis, in front of the rail -parts.append(tile(0, JB - 1.3, BOT, 'mod', uspan=3)) -parts.append(label(0, JB - 1.3, BOT, 'Modify', 'focus-agnostic', uspan=3)) +# ---------- 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')) +parts.append(label(0, 0, BOT, 'Review', None)) +parts.append(tile(1, 0, BOT, 'empty', dash=True)) +parts.append(label(1, 0, BOT, '≡ Review', "a Prism's mend is total", cls='ghost-t')) +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): @@ -160,9 +162,9 @@ def arrow(x0, y0, x1, y1): parts.append(f'write-only (bottom)') # layer captions on the right -for dy, name, sub in [(TOP, 'read-only', 'no write side — collapses to the read axis'), +for dy, name, sub in [(TOP, 'read-only', 'no from at all — collapses to the focus axis'), (MID, 'read-write', None), - (BOT, 'write / build only', 'no read side — collapses to the write axis')]: + (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: From fafde0d0a31b5d33de8ffbb420da94c907127e05 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 09:42:59 +0200 Subject: [PATCH 08/12] =?UTF-8?q?docs(site):=20taxonomy=20=E2=80=94=20read?= =?UTF-8?q?-only=20families=20span=20the=20source=20axis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend Getter / AffineFold / Fold into bars across the source axis: read-only optics pin B = Unit (the terminal type, NOT Nothing), so a function into Unit exists uniformly for every input and the vestigial `from` is vacuously satisfied at every source nature — the read-only families honestly hold the whole axis, the exact dual of Modify spanning the focus axis on the write-only layer. (Nothing in that slot would admit no `from` at all.) With this, all three capability layers are full planes over the same focus × source footprint. Co-Authored-By: Claude Fable 5 --- site/docs/optics.md | 19 ++++++++++----- .../laika-static/static/optic-taxonomy-3d.svg | 11 +++++---- site/tools/gen-taxonomy-svg.py | 23 +++++++++++-------- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/site/docs/optics.md b/site/docs/optics.md index 2a4a1957..02747010 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -26,12 +26,19 @@ is **three-axis**: How to read it: -- The **top layer** is read-only — no `from`, so it collapses to the - focus axis: Getter (total), AffineFold (fallible), Fold (multiple). - 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 **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 diff --git a/site/laika-static/static/optic-taxonomy-3d.svg b/site/laika-static/static/optic-taxonomy-3d.svg index 479b2df2..9281e9af 100644 --- a/site/laika-static/static/optic-taxonomy-3d.svg +++ b/site/laika-static/static/optic-taxonomy-3d.svg @@ -39,11 +39,12 @@ - + Getter - -AffineFold - + +AffineFold +B = Unit + Fold Iso @@ -88,7 +89,7 @@ read-only (top) write-only (bottom) read-only -no from at all — collapses to the focus axis +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 index 00f3f757..a501041a 100644 --- a/site/tools/gen-taxonomy-svg.py +++ b/site/tools/gen-taxonomy-svg.py @@ -8,10 +8,13 @@ vertical (layers): capability — read-only (top), read-write (middle), write/build-only (bottom) -One-way layers collapse to the focus axis (read-only has no `from`; write-only -rails by the focus shape its build consumes), so both rails run parallel along u. -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. +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 @@ -92,11 +95,13 @@ def label(i, j, dy, name, sub=None, cls="fam", uspan=1, vspan=1): x1, y1 = pt(i, j, BOT) parts.append(f'') -# ---------- TOP layer: read-only (rail along u; no write side, v collapses) ---------- -JR = 1.0 # rail sits on the middle v-row so it hovers over the grid's centre +# ---------- 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, JR, TOP, 'ro')) - parts.append(label(i, JR, TOP, name)) + 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 @@ -162,7 +167,7 @@ def arrow(x0, y0, x1, y1): parts.append(f'write-only (bottom)') # layer captions on the right -for dy, name, sub in [(TOP, 'read-only', 'no from at all — collapses to the focus axis'), +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) From 1a74e2fbe780324ad64b1fa8d18afc50ae0eb7b0 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 09:58:30 +0200 Subject: [PATCH 09/12] =?UTF-8?q?docs(site):=20taxonomy=20=E2=80=94=20Revi?= =?UTF-8?q?ew=20spans=20both=20build-half=20cells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review is the build half of Iso AND Prism (a mend is total), so it is one tile spanning both cells on the write-only plane — not a tile plus a ≡-ghost leaving a visual hole. Bottom plane is now gaplessly tiled: Review (span) + Unfold on the total row, Modify on the contextual row, fallible build (planned) on the fallible row. Co-Authored-By: Claude Fable 5 --- site/docs/optics.md | 8 ++++---- site/laika-static/static/optic-taxonomy-3d.svg | 8 +++----- site/tools/gen-taxonomy-svg.py | 6 ++---- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/site/docs/optics.md b/site/docs/optics.md index 02747010..77683ecd 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -53,10 +53,10 @@ How to read it: 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 below Iso (total focus, total - source) — and below Prism the cell is *≡ Review* again, because a - `mend` is total; Unfold below Traversal's total flank (multiple - focus: `embed: F[B] => T`, Fold's mirror on the same `Forget[F]` + 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 diff --git a/site/laika-static/static/optic-taxonomy-3d.svg b/site/laika-static/static/optic-taxonomy-3d.svg index 9281e9af..0c660fd2 100644 --- a/site/laika-static/static/optic-taxonomy-3d.svg +++ b/site/laika-static/static/optic-taxonomy-3d.svg @@ -67,11 +67,9 @@ fallible each planned - -Review - -≡ Review -a Prism's mend is total + +Review +Iso's and Prism's build half — mend is total Unfold diff --git a/site/tools/gen-taxonomy-svg.py b/site/tools/gen-taxonomy-svg.py index a501041a..dbefe682 100644 --- a/site/tools/gen-taxonomy-svg.py +++ b/site/tools/gen-taxonomy-svg.py @@ -126,10 +126,8 @@ def label(i, j, dy, name, sub=None, cls="fam", uspan=1, vspan=1): # ---------- 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')) -parts.append(label(0, 0, BOT, 'Review', None)) -parts.append(tile(1, 0, BOT, 'empty', dash=True)) -parts.append(label(1, 0, BOT, '≡ Review', "a Prism's mend is total", cls='ghost-t')) +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)) From f947e2475e4a709e99514ac0135d0f0ec3c829fc Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 10:10:27 +0200 Subject: [PATCH 10/12] docs(site): group circe / avro / jsoniter under an Integrations submenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the three integration pages into site/docs/integrations/ with their own directory.conf (title + order: circe, avro, jsoniter); the root navigationOrder lists the directory in their old slot. All relative links rewritten (root pages → integrations/.md, moved pages → ../.md, intra-integration links unchanged) and the README's absolute eo.constructive.dev/.html URLs updated to /integrations/. Page URLs change accordingly (old root-level circe/avro/jsoniter.html paths are gone). Co-Authored-By: Claude Fable 5 --- README.md | 20 ++++++++++---------- site/docs/cookbook.md | 14 +++++++------- site/docs/directory.conf | 4 +--- site/docs/extensibility.md | 2 +- site/docs/generics.md | 2 +- site/docs/index.md | 10 +++++----- site/docs/{ => integrations}/avro.md | 8 ++++---- site/docs/{ => integrations}/circe.md | 6 +++--- site/docs/integrations/directory.conf | 6 ++++++ site/docs/{ => integrations}/jsoniter.md | 2 +- site/docs/migration-from-monocle.md | 2 +- 11 files changed, 40 insertions(+), 36 deletions(-) rename site/docs/{ => integrations}/avro.md (98%) rename site/docs/{ => integrations}/circe.md (98%) create mode 100644 site/docs/integrations/directory.conf rename site/docs/{ => integrations}/jsoniter.md (99%) diff --git a/README.md b/README.md index ac17b6a7..0c0c4ab3 100644 --- a/README.md +++ b/README.md @@ -79,24 +79,24 @@ personStreet.modify(_.toUpperCase)(alice) // address.street := "MAIN ST" - [`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/circe.html#jsonprism) — +- [`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 @@ -107,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/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 6c62b316..38d0eaa2 100644 --- a/site/docs/extensibility.md +++ b/site/docs/extensibility.md @@ -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 989b2302..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 diff --git a/site/docs/index.md b/site/docs/index.md index 95362e75..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. @@ -70,12 +70,12 @@ types alike. 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 ecd72349..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 / @@ -576,7 +576,7 @@ Avro mirror of [`Plated[Json]`](circe.md). `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 aba50124..133536c0 100644 --- a/site/docs/migration-from-monocle.md +++ b/site/docs/migration-from-monocle.md @@ -110,7 +110,7 @@ Monocle's `monocle-circe` module only provides a 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). +level of the path. See [Circe integration](integrations/circe.md). ### Plated is stack-safe and composes as an optic From 81a12f4c6c0636fa0b0cd33313b796d62a0f24c4 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 10:30:30 +0200 Subject: [PATCH 11/12] =?UTF-8?q?docs(site):=20rewrite=20the=20Monocle=20m?= =?UTF-8?q?igration=20page=20=E2=80=94=20accuracy,=20tone,=20currency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fact-checked against monocle-core/monocle-law 3.2 (via cellar) and Maven Central, and corrected accordingly: - REMOVED "Polymorphic constructors" as a divergence — Monocle ships the full PLens/PPrism/POptional/PTraversal/PSetter hierarchy; the cheat sheet now maps PLens.apply to Lens.pLens instead. - REMOVED "Discipline law instances" as a divergence — Monocle ships monocle-law with the same discipline rule-set pattern; reframed as "law testing ports directly" (an import swap), which is the truth. - CORRECTED the circe section: the module is circe-optics (not "monocle-circe"), it is published for Scala 3, and JsonPath does navigate the AST without a full decode. eo's actual differentiators stated instead: multi-field foci, the observable Ior failure channel, and the extension to Avro records and raw jsoniter bytes. - Tone: Monocle is never "incapable" or "missing" — phrasings are now "prefers", "chooses", "expresses through", "leaves to dedicated libraries", with its design goals credited. Structure per review: a junior-friendly "Why migrate?" opener stating eo's actual positioning — performance and industrial application; the internals are not the most elegant code, what you buy is efficiency, stack safety, interoperability (wire formats), and compositional reach. Capability content refreshed to current eo (Unfold, Modify, recursion schemes, the 11-family compiler-pinned matrix, BijectionIso full-cover upgrade). Cheat sheet moved to the bottom and extended (pLens row, laws row, schemes row). Benchmark claims restated within what the published tables support (~2x at depth 6, ~3x traversals, Monocle wins some single-hop cells). Co-Authored-By: Claude Fable 5 --- site/docs/migration-from-monocle.md | 289 ++++++++++++++++++---------- 1 file changed, 187 insertions(+), 102 deletions(-) diff --git a/site/docs/migration-from-monocle.md b/site/docs/migration-from-monocle.md index 133536c0..327631b2 100644 --- a/site/docs/migration-from-monocle.md +++ b/site/docs/migration-from-monocle.md @@ -1,58 +1,99 @@ # 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. + +## Why migrate? + +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: -## Cheat sheet +```scala +libraryDependencies += "dev.constructive" %% "cats-eo-laws" % "@VERSION@" % Test +``` -| 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 => …)` | `Modify[S, S, A, A](f => s => …)` | -| *(no equivalent — build-only optics are not standalone citizens in Monocle)* | `Review[S, A](build)` (build-only, one focus) and `Unfold[T, B, F]` (build-only, many: `embed: F[B] => T` — recursion-scheme algebras, aggregation); see [Optics → Review](optics.md#review) / [Unfold](optics.md#unfold) | -| `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 (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 | +```scala +// before: import monocle.law.discipline.LensTests +import dev.constructive.eo.laws.discipline.LensTests -## Where EO diverges +checkAll("Lens[Person, Int]", LensTests[Person, Int](ageL).lens) +``` -### Polymorphic constructors +Every public optic family has a matching `FooLaws` / `FooTests` +pair, including the families Monocle prefers not to ship (AffineFold, +Review, Unfold, Modify, MultiFocus). -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`. +## Where eo differs at the call site ### Cross-family composition: `.andThen` auto-morphs -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: +Monocle gets cross-family composition from its inheritance +hierarchy: every optic *is* a weaker optic, so `andThen` meets at +the common ancestor — an elegant design. cats-eo's families don't +share a subtyping hierarchy; instead `Optic.andThen` is +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 is the same one you're used to — one +`.andThen`, whether the two optics share a carrier or not: ```scala mdoc:silent import dev.constructive.eo.data.Affine @@ -75,71 +116,115 @@ 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. +The payoff: composition is carrier-level (one `Composer` per pair of +carriers), not family-level. Adding a new optic family means +supplying carrier instances; the cross-family bridges come for free. -### Getter / Modify compose by collapse, not by carrier +### Read-only and write-only chains collapse -Getter's `T = Unit` and Modify's `ModifyF` carrier share no -`AssociativeFunctor` instance — instead, a chain that touches a -read-only optic anywhere collapses to the read-only join +A chain that touches a read-only optic anywhere collapses to the +read-only family at the join of the read strengths (`lens.andThen(getter)` → Getter, `prism.andThen(getter)` → -AffineFold, `traversal.andThen(getter)` → Fold), and a chain into -a Modify collapses the read side (`lens.andThen(modify)` → -Modify). `getter.andThen(modify)` itself is void by design — there -is nothing to write through. See the -[composition matrix](optics.md#composition-matrix) for every pair. +AffineFold, `traversal.andThen(getter)` → Fold), and a chain into a +write-only `Modify` collapses the read side (`lens.andThen(modify)` +→ Modify). `getter.andThen(modify)` is void by design — there is +nothing to write through. The +[composition matrix](optics.md#composition-matrix) pins every pair. + +### A larger family space + +Beyond the classic eight, cats-eo ships families Monocle prefers to +express through other means or leave to dedicated libraries: + +- **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). ### Traversal carrier -cats-eo's `Traversal` is a single carrier: - -- `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. - -### JsonPrism has no Monocle equivalent - -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](integrations/circe.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. + +### JSON, Avro, and raw bytes + +[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. ### Plated is stack-safe and composes as an optic -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 `Modify` — `everywhere.andThen(prism).modify(f)` -applies an ordinary optic at every depth — which Monocle has no -equivalent for. See the [cookbook recipe](cookbook.md). - -## Discipline law instances +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). -Downstream projects can reuse the same `checkAll` pattern they -know from cats. `cats-eo-laws` ships the rule-sets: - -```scala -libraryDependencies += "dev.constructive" %% "cats-eo-laws" % "@VERSION@" % Test -``` - -```scala -import dev.constructive.eo.laws.discipline.LensTests - -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 | From f924bb007a1c5b5bdbb28c7c616e12648736b319 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Thu, 11 Jun 2026 10:56:20 +0200 Subject: [PATCH 12/12] =?UTF-8?q?docs(site):=20migration=20page=20?= =?UTF-8?q?=E2=80=94=20fix=20the=20two=20divergence=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both subsections claimed differences Monocle doesn't actually have: - ".andThen auto-morphs" implied cross-family composition is an eo feature; Monocle's hierarchy composes across families just as well (and the page already said so). Rewritten as "Bring your own optic": the real difference is that eo has NO extension hierarchy — composition is typeclass-driven over the carrier, so an optic from outside the shipped families needs no blessed place in a subtype tree: implement the trait, provide the instances, compose with everything. Links to Extensibility. - "Read-only and write-only chains collapse" implied read-only collapse is an eo feature; Monocle's hierarchy lands on Getter/Fold just as naturally. Rewritten as "The one-way side, built out in both directions": what eo actually adds is the fully populated build/write-only direction (Modify, Review, Unfold; fallible tier planned) and the preliminary recursion-schemes integration — schemes as optics so recursive-structure algorithms sit in the same pipeline as getters and modifiers, heading toward whole request-to-Kafka flows as one composed optic. Leads into the family-roster section. Co-Authored-By: Claude Fable 5 --- site/docs/migration-from-monocle.md | 79 +++++++++++++++++++---------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/site/docs/migration-from-monocle.md b/site/docs/migration-from-monocle.md index 327631b2..501e7c71 100644 --- a/site/docs/migration-from-monocle.md +++ b/site/docs/migration-from-monocle.md @@ -82,18 +82,26 @@ Review, Unfold, Modify, MultiFocus). ## Where eo differs at the call site -### Cross-family composition: `.andThen` auto-morphs - -Monocle gets cross-family composition from its inheritance -hierarchy: every optic *is* a weaker optic, so `andThen` meets at -the common ancestor — an elegant design. cats-eo's families don't -share a subtyping hierarchy; instead `Optic.andThen` is -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 is the same one you're used to — one -`.andThen`, whether the two optics share a carrier or not: +### Bring your own optic + +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. + +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 @@ -116,20 +124,39 @@ val appConfig = val appTimeout = appConfig.andThen(timeoutOpt) ``` -The payoff: composition is carrier-level (one `Composer` per pair of -carriers), not family-level. Adding a new optic family means -supplying carrier instances; the cross-family bridges come for free. - -### Read-only and write-only chains collapse - -A chain that touches a read-only optic anywhere collapses to the -read-only family at the join of the read strengths -(`lens.andThen(getter)` → Getter, `prism.andThen(getter)` → -AffineFold, `traversal.andThen(getter)` → Fold), and a chain into a -write-only `Modify` collapses the read side (`lens.andThen(modify)` -→ Modify). `getter.andThen(modify)` is void by design — there is -nothing to write through. The -[composition matrix](optics.md#composition-matrix) pins every pair. +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. + +### The one-way side, built out in both directions + +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**: + +- 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. + +That ambition is why the family roster below is as large as it is. ### A larger family space