MultiFocus
MultiFocus[F][X, A] = (X, focus half) is cats-eo's multi-focus
carrier: a structural leftover X paired with a focus half that is
either the carrier's own F[A] or an index-free bundle — one value,
known without consulting an index. A single carrier, specialised through
the type parameter F, backs every optic that focuses more than one
value at once — traversals, grates, algebraic lenses, and aggregating
(Kaleidoscope) reads.
What you get: the optic's surface is exactly the intersection of cats's
typeclass hierarchy on F with what the generic carrier body supports.
Pick an F and the methods light up automatically — .modify
(Functor), .foldMap (Foldable), .modifyA (Traverse), .at(i)
(Representable), .collectWith / .collectMap / .collectList
aggregation, and same-carrier .andThen — with no new carrier, law
surface, or AssociativeFunctor instance to write.
For the mechanical intro see Optics → MultiFocus; for runnable patterns the Cookbook ships two end-to-end recipes that exercise the prototypical Grate and Kaleidoscope shapes.
Sub-shapes
The choice of F selects a sub-shape, each suited to a different
multi-focus job:
| Sub-shape | F |
What it's for |
|---|---|---|
AlgLens[F] |
F: Functor / Foldable / Traverse |
Algebraic ("classifier") lenses — a focus computed as a fold / classification over the structure, broadcast back on write. |
| Kaleidoscope | F: Apply |
Aggregating reads and batch-relative rewrites — .collectWith / .collectMap / .collectList. |
| Grate | Function1[X0, *] |
Uniform rewrite across a fixed shape — homogeneous tuples and Naperian / representable containers (MultiFocus.tuple / representable); reads land on a position with .at(i). |
| PowerSeries | PSVec |
Element-wise traversal of a collection with downstream .andThen composition — the Traversal.each carrier; carries the hand-tuned mfAssocPSVec fast paths (MultiFocusSingleton for Lens morphs, MultiFocusPSMaybeHit for Prism / Optional). |
FixedTraversal[N] |
PSVec |
Fixed-arity traversal — the Traversal.{two,three,four} factories tabulate their known arity into the PowerSeries carrier, so they compose like each. |
All five share one runtime shape — a leftover paired with a focus container — so they live as a single carrier rather than five. See Historical landmarks for that consolidation and its measured payoff.
Index-free vs tabulating bundles
The Grate sub-shape is where two different optics meet on the same
type. A broadcast optic (an Iso reshaped through
forgetful2multifocusFunction1) holds one focus and reads it at
whatever index you like; a tabulating optic (MultiFocus.tuple,
representable) reads a real position. Both are
Optic[S, T, A, B, MultiFocus[Function1[X0, *]]], so the carrier says
which is which per bundle:
| Bundle | Built by | .broadcast |
.foci |
|---|---|---|---|
| index-free | a broadcast optic's to, MultiFocus.broadcast, a composition whose write collapsed |
Some(value) |
the constant _ => value |
| tabulating | every other factory | None |
the tabulation itself |
import dev.constructive.eo.data.MultiFocus
val indexFree = MultiFocus.broadcast[Boolean, Unit, String]((), "v")
(indexFree.broadcast, indexFree.foci(true), indexFree.foci(false))
// res0: Tuple3[Option[String], String, String] = (Some("v"), "v", "v")
Because the shape lives in the data, it survives map / collectWith
/ andThen, and mfAssocFunction1 reads it alongside the optic-level
witness it already had (Function1BroadcastOptic): a bundle says whether
this value is index-free, the class says whether this optic builds one
value per position, and the two agree wherever they overlap. Consequences
observable at a composite's boundary:
- Read — an index-free outer is read once and its value handed to the inner; a tabulating outer is read per index. Neither invents an index.
- Write — a composite whose read was index-free hands the outer an
index-free half, so the outer's rebuild never samples a closure; a
composite whose outer is tabulating writes each position its own value
(
MultiFocus.tuple.andThen(isoShim).modify(f)reaches every slot with its ownf). - Two tabulating sides advance together.
MultiFocus.tuple∘MultiFocus.tuplereads one position per index (the shared index set cannot address(outer position, inner position)pairs), and its write is the exact inverse of that read whenever the focus type is stable —modify/replace/setleave every position it did not address untouched. A write that changes the focus type has no way to put the unread positions back and falls back to writing each addressed position;Optic.andThentells the kernel which case it is, at compile time — by mixingOptic.SameFocusinto the composite it builds, which is where a stability fact belongs: on the optic, not in the composition algebra.
.foci stays total for both shapes — the index-free case carries its
F[A] image — so consumers that only want "the focus vector" are
unchanged; .broadcast is for consumers that would otherwise have to
invent an index. The representation behind them is internal: the two
handles above are the whole public surface.
Every read that needs an index is supplied a real one — the bridge's
from reads at the RepresentativeIndex[X0] witness it was handed (see
the Grate sub-shape) — and no
operation invents a value it does not have: every write is handed the
leftover (and, for a composite, the inner reads) that its own read
produced, including the collapsing List aggregate.
The general flexibility win
MultiFocus[F][X, A] = (X, focus half) is just a pair. The
carrier ships no typeclass machinery of its own; it inherits whatever
F brings.
That distinguishes cats-eo's encoding from monolithic-carrier
alternatives — Monocle's per-family classes (Lens, Prism,
Traversal, IndexedTraversal, …) bake the typeclass requirements
into the carrier definition itself. Adding a new optic family means
introducing a new carrier with a new typeclass set. cats-eo's
existential encoding lets the user add a new F and the existing
MultiFocus surface lights up automatically: .modify if F has
Functor, .foldMap if F has Foldable, .modifyA if F has
Traverse, .at(i) if F has Representable, same-carrier
.andThen if F has Traverse + MultiFocusFromList. No new carrier
file, no new law surface, no new AssociativeFunctor instance — the
generic body in MultiFocus.scala covers it.
The sub-shapes are the demonstration: each is just a different F
plugged into the same shape. The PSVec case adds a hand-tuned
same-carrier specialisation (mfAssocPSVec) for perf, but its
capability set is the generic one, lit up by cats.Functor[PSVec]
etc. shipped in the companion.
The capability set
Every method below is gated on a typeclass that F either has or
doesn't have. Bring an F to the table and the optic's surface is
exactly the intersection of cats's hierarchy on F with what the
generic body supports.
import cats.data.ZipList
import cats.instances.list.given
import cats.instances.option.given
import cats.instances.function.given
import dev.constructive.eo.optics.Optic.*
import dev.constructive.eo.data.MultiFocus
import dev.constructive.eo.data.MultiFocus.given
import dev.constructive.eo.data.MultiFocus.{at, collectList, collectMap, collectWith}
.modify — Functor[F]
val listMF = MultiFocus.apply[List, Int]
listMF.modify(_ + 1)(List(1, 2, 3))
// res1: List[Int] = List(2, 3, 4)
mfFunctor[F: Functor] provides ForgetfulFunctor[MultiFocus[F]],
which Optic.modify consumes. Functor[List] arrives via
cats.instances.list.given; the same body lights up for
Vector, Option, ZipList, PSVec, Function1[X, *], and any
user-defined F: Functor.
.foldMap — Foldable[F]
listMF.foldMap(identity[Int])(List(1, 2, 3, 4))
// res2: Int = 10
mfFold[F: Foldable] provides ForgetfulFold[MultiFocus[F]]. The
carrier-wide Optic.foldMap extension picks it up — no
MultiFocus-specific extension method ships, the read-only escape
flows through the carrier-generic body.
.modifyA — Traverse[F]
def safeRecip(d: Double): Option[Double] =
if d == 0.0 then None else Some(1.0 / d)
val doubleMF = MultiFocus.apply[List, Double]
doubleMF.modifyA[Option](safeRecip)(List(1.0, 2.0, 4.0))
// res3: Option[List[Double]] = Some(List(1.0, 0.5, 0.25))
doubleMF.modifyA[Option](safeRecip)(List(1.0, 0.0, 4.0))
// res4: Option[List[Double]] = None
mfTraverse[F: Traverse] provides
ForgetfulTraverse[MultiFocus[F], Applicative]. Failures short-circuit
on whatever Applicative[G] the user supplies.
.collectMap — Functor-broadcast aggregation
val zipMF = MultiFocus.apply[ZipList, Double]
// Column-wise mean: aggregator sees the whole ZipList, returns
// the mean, the broadcast fills back through Functor[ZipList].map.
zipMF.collectMap[Double](zl => zl.value.sum / zl.value.size.toDouble)(
ZipList(List(1.0, 2.0, 3.0, 4.0))
)
// res5: ZipList[Double] = cats.data.ZipList@b588d106
.collectMap[B](agg: F[A] => B) requires only Functor[F]. The
aggregator collapses the entire F[A] focus to a single B; the
broadcast F.map(_ => b) puts the aggregate back into every position,
preserving the F-shape exactly.
.collectWith — the algebraic-lens universal
collectMap's aggregate never sees the individual focus.
.collectWith(agg: F[A] => A => B) is the general map-shaped
collect: the curried aggregate sees the whole batch ONCE, and the
A => B it returns runs per position — so batch-relative rewrites
(distance-from-mean, share-of-total) are one expression. It
subsumes both map-shaped siblings — collectMap(agg) =
collectWith(fa => _ => agg(fa)) and modify(f) = collectWith(_ => f),
pinned as discipline laws MF4 / MF5 — and requires only
Functor[F], like collectMap.
// Batch-relative rewrite: subtract the column mean from every slot.
zipMF.collectWith { zl =>
val mean = zl.value.sum / zl.value.size.toDouble
v => v - mean
}(ZipList(List(1.0, 2.0, 3.0, 4.0)))
// res6: ZipList[Double] = cats.data.ZipList@4501ebdd
// Type-changing via the pApply factory: each reading becomes a
// (value, distance) pair — the report-row shape.
MultiFocus.pApply[List, Double, (Double, Double)].collectWith { xs =>
val mean = xs.sum / xs.size
v => (v, v - mean)
}(List(1.0, 2.0, 3.0, 4.0))
// res7: List[Tuple2[Double, Double]] = List(
// (1.0, -1.5),
// (2.0, -0.5),
// (3.0, 0.5),
// (4.0, 1.5)
// )
The second call runs through MultiFocus.pApply[F, A, B] — the
polymorphic counterpart to the generic MultiFocus.apply[F, A]
factory (apply is now pApply[F, A, A]), sound because the
factory's rebuild is identity on the written-back F[B].
.collectList — List-only cartesian collapse
listMF.collectList(_.sum)(List(1, 2, 3, 4))
// res8: List[Int] = List(10)
MultiFocus[List]-only, produces List(agg(fa)) — a one-element
output regardless of input length. Reproduces the v1
Reflector[List]'s cartesian-singleton choice at the call site
without a typeclass.
.at(i) — Representable[F]
val grateF = MultiFocus.representable[[a] =>> Boolean => a, Int]
val payment: Boolean => Int = b => if b then 100 else 0
// payment: Function1[Boolean, Int] = repl.MdocSession$MdocApp$$Lambda/0x0000000022fd2a28@533788ff
grateF.at(true)(payment)
// res9: Int = 100
grateF.at(false)(payment)
// res10: Int = 0
.at(i: F.Representation) reads the focus at a representative
index — typed against the cats Representable[F] instance. For
Function1[X, *] this is the natural apply(i) lookup; for
custom Naperian containers the user's Representable witness
defines the index space. Surface gated on Representable[F],
which most Fs with Functor + Foldable + Distributive already
admit.
Why two collect variants
The v1 Reflector[F] typeclass collapsed differently per F:
| Instance | reflect(fa)(f) returns |
Functor.map fits? | Applicative.pure fits? |
|---|---|---|---|
forList |
List(f(fa)) (singleton / cartesian) |
NO (would broadcast) | YES |
forZipList |
ZipList(List.fill(size)(f(fa))) (length-preserving) |
YES | NO (no top-level pure) |
forConst[M] |
fa.retag[B] (phantom retag) |
YES | YES |
forId |
f(fa) |
YES | YES |
No single derivation from Apply[F] covers all four behaviours
uniformly — picking one would have silently changed the v1 List
semantics. The chosen split (Functor-broadcast as the carrier-wide
default, List-cartesian as the call-site extension) is honest about
the choice without cluttering the discipline surface.
.collectWith later generalised the map-shaped side: it is the
universal that collectMap and modify specialise (laws MF4 /
MF5), so the surviving split is map-shaped (collectWith and its
special cases, Functor-derivable) versus shape-collapsing
(collectList, the one behaviour no map-shaped combinator can
express).
Composability profile
MultiFocus[F] has shipped inbound bridges from every classical
read-write family (conditional on F's typeclass set) and two
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
Composition limits below.
Inbound bridges
| Bridge | Composer | F constraints |
Notes |
|---|---|---|---|
Iso → MF[F] |
forgetful2multifocus |
Applicative + Foldable |
Broadcasts the Iso's S => A to a singleton F[A]. |
Iso → MF[Function1[X0, *]] |
forgetful2multifocusFunction1 |
RepresentativeIndex[X0] |
Direct broadcast; lights up Iso → Traversal.{two,three,four} and Iso → MultiFocus.representable / tuple. The index is the bridge's read position, not a write index — see the Grate sub-shape. |
Lens → MF[F] |
tuple2multifocus |
Applicative + Foldable |
Mixes in MultiFocusSingleton so the same-carrier mfAssoc fast-path fires. Alongside tuple2multifocusPSVec for the F = PSVec specialisation. |
Prism → MF[F] |
either2multifocus |
Alternative + Foldable |
Miss branch produces MonoidK[F].empty. PSVec specialisation: either2multifocusPSVec. |
Optional → MF[F] |
affine2multifocus |
Alternative + Foldable |
Same shape as Prism. PSVec specialisation: affine2multifocusPSVec. |
Forget[F] → MF[F] |
forget2multifocus |
(none) | Lifts a Fold into a MultiFocus on the same F. |
Each inbound bridge produces a MultiFocus[F]-carrier optic that
inherits the full capability set above without per-bridge surface
work. The PSVec-specialised bridges (tuple2multifocusPSVec, either2multifocusPSVec,
affine2multifocusPSVec) sidestep the generic Applicative[F] /
Alternative[F] constraint because PSVec admits neither — instead
they directly call PSVec.singleton / PSVec.empty and mix in
MultiFocusPSMaybeHit for the Prism / Optional fast-paths inside
mfAssocPSVec's body.
Same-carrier .andThen
Three AssociativeFunctor[MultiFocus[F], _, _] instances ship,
specialised by F:
mfAssoc— the generic body forF: Traverse + MultiFocusFromList. CoversList,Vector,Option,cats.data.Chain. Singleton fast-path viaMultiFocusSingleton(so morphed Lenses skip the per-elementF.pureround-trip).mfAssocFunction1— the absorbed-Grate sub-shape's body forF = Function1[X0, *].Z = AssocF1Z[Xo]— the outer's leftover plus the inner reads the composite's read observed (leftover, read bundle, shape), so the outer's ownfromreceives what its owntoproduced and a composite inner reads its context back. Reads are per index in every shape: the inner's bundle is read at the same index its element came from. The write has three cases, and the rebuild is a closure-on-closure in all of them:
- a broadcast inner — the
Iso → MultiFocus[Function1]bridge and anything composed from it, witnessed by theFunction1BroadcastOpticit produces (private[eo]) — holds exactly one focus, so its write is composed per index: positioniis built from the written focusi(broadcastFrom). That is what makesgrate ∘ isorewrite every position instead of collapsing onto position 0. Its ownfromis the one place the library has to read a bundle it did not build, so the bridge is handed aRepresentativeIndex[X0]at construction and reads there — a real index, never a sentinel (see the Grate sub-shape).
- an index-free bundle — the carrier says so itself (below) — collapses to one value that the outer rebuilds with, handed back as an index-free half.
- a tabulating ∘ tabulating composite (e.g.
MultiFocus.tupleon both sides) advances both sides together, so its read is a diagonal; the write is the exact inverse of that read whenever the inner optic says its read and write types coincide (Optic.SameFocus, carried by the monomorphic factories and mixed into everyandThencomposite), and a per-position value write otherwise. mfAssocPSVec— the absorbed-PowerSeries body forF = PSVec. Parallel-arrayAssocSndZleftover (saves the per-element Tuple2 the generic body would build). AlwaysHit fast-path viaMultiFocusSingleton, MaybeHit fast-path viaMultiFocusPSMaybeHit(Prism / Optional inners skip the per-element wrapper allocation).
Outbound — ModifyF and a read-only Forget[F] escape
import dev.constructive.eo.compose.Composer
import dev.constructive.eo.data.ModifyF
val modify = summon[Composer[MultiFocus[List], ModifyF]].to(listMF)
modify.modify(_ * 2)(List(1, 2, 3))
// res11: List[Int] = List(2, 4, 6)
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 modify.andThen(modify)
does work — see the Modify section 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
structural leftover, keep the focused F[A]. It is the structural
inverse of the forget2multifocus inbound bridge above and ships
only for T = Unit optics: once Forget drops the leftover it can't
reconstruct a T ≠ Unit target, and that same T = Unit restriction is
what keeps the bidirectional Forget[F] ⇄ MultiFocus[F] pair from making
Morph resolution ambiguous (see Composition
limits below). It's the explicit-Composer
companion to the carrier-wide .foldMap / .headOption / .length
read methods.
Composition limits
One further outbound direction is structurally rejected outright,
and the → Forget direction is rejected only for a different effect
(G ≠ F) — the same-F case ships as the multifocus2forget escape
above. The rationale lives at the bottom of MultiFocus.scala and in
docs/research/2026-04-23-composition-gap-analysis.md §3.2.6:
Composer[MultiFocus[F], Direct](MultiFocus widens to Iso/Getter). Type-level encodable, butforgetful2multifocusalready ships in the OPPOSITE direction. Adding the reverse would create a bidirectional Composer pair, which theMorphresolution explicitly forbids — bothMorph.leftToRightandMorph.rightToLeftwould match for anyIso × MultiFocuspair, surfacing as ambiguous-implicit and breaking everyiso.andThen(multifocus)call site. Workaround:multiFocus.to(s)._2for the read side.Composer[MultiFocus[F], Forget[G]]forG ≠ F(MultiFocus widens to a different effect's Traversal/Fold). Generic inS, T, A, B. The morphedtohas the MultiFocus'sF[A]in hand but must yieldG[A], and with no relationship betweenFandGthere's no way to convert one to the other. The same-Fcase (Forget[F]) is exactly themultifocus2forgetread-only escape documented above (restricted toT = Unit), so it ships rather than being rejected. Users wanting fold/traverse semantics on a MultiFocus's slots reach for that escape, construct theForget[F]-carrier optic directly, or stay on MultiFocus and use.foldMap/.modifyA.
Lens / Prism / Optional → MultiFocus[Function1[X0, *]] is also
structurally absent: Function1[X0, *] lacks Foldable /
Alternative, so the constraint set on tuple2multifocus[F:
Applicative: Foldable] (and the Prism / Optional variants) doesn't
fire for the Naperian carrier. The Iso bridge
forgetful2multifocusFunction1 is the only inbound for the
absorbed-Grate sub-shape — so chains of the form
iso.andThen(MultiFocus.tuple[...]) work, but lens.andThen(grate)
does not — and it now asks for a RepresentativeIndex[X0] for the
grate's index type. That witness exists off the companion for every
index type the shipped factories fix with a canonical value (Int
for tuple, Boolean, Unit, singletons), so those chains stay
import-free; a grate over an algebraic or phantom index needs a
given RepresentativeIndex[X] = RepresentativeIndex.at(v) in scope,
and an uninhabited index type has none to give, so the bridge
refuses it. The witness is only read by a bridged optic's own from
(a position it must pick but never observes on the shipped paths); a
grate ∘ iso composition still rebuilds every position from its own
focus. (Traversal.two/three/four are unaffected: they ride
MultiFocus[PSVec] and compose freely.)
The constraint gap is not a missing instance, it is arithmetic: a Lens
write-back would have to pick one B out of an X0 => B bundle
(Foldable can't enumerate a function's codomain, hence no lawful
instance), and a Prism / Optional miss would need
Alternative[Function1[X0, *]], i.e. an X0 => A for a type with no
A to return. The same wall blocks the read-collapse: a Getter /
AffineFold / Fold over every position would have to enumerate the
codomain. So the Naperian sub-shape composes only with Iso, Modify,
and itself — the full pinned grid, cell by cell, is
QA → The Grate sub-shape
(GrateShapeSpec) with the composed behaviour pinned by
MultiFocusFunction1Spec.
Worked examples
Two end-to-end recipes in the Cookbook cover the prototypical post-fold shapes:
- Recipe A — Prototypical Grate-shape via
MultiFocus.tuple— the "broadcast a uniformA => Bover a homogeneous tuple" idiom. Exercises the absorbed-Grate sub-shapeMultiFocus[Function1[Int, *]]. - Recipe B — Prototypical Kaleidoscope-shape via
.collectWith/.collectMap/.collectList— the "applicative-aware aggregation" idiom, told as report-row preparation: broadcast baseline, cartesian footer, and the type-changingpApply+collectWithbatch-relative rewrite.
The third post-fold shape — PowerSeries downstream composition,
MultiFocus[PSVec] letting .andThen continue past
Traversal.each into a downstream Lens (which the deleted
Traversal.forEach shape, Forget[T]-based and terminal,
couldn't) — is exercised throughout the cookbook's decoupling
recipes: the lineAmounts chain in "Depend only on what's needed"
is exactly Lens → each → Lens.
Historical landmarks
This page absorbed the previous "Grate" and "MultiFocus" sections
of optics.md in the post-fold doc sweep. The empirical
justification for each absorbed carrier lives in a research spike
on the worktree branch that landed it:
- AlgLens + Kaleidoscope merge — the foundational fold;
Reflector[F]deleted, two.collectflavours (.collectMapFunctor-broadcast and.collectListcartesian) replace the v1 typeclass. Seedocs/research/2026-04-28-multifocus-unification.md. - Grate fold — the lead-position field's empirical dead-code
deletion; +20% perf on
Grate.modify; absorbed factories ship asMultiFocus.representable/MultiFocus.tuple. The spike's research doc was lost in consolidation; the surviving evidence is the carrier-doc comment inMultiFocus.scalaand the absorbed factory code. The field's last remnant — therepr0parameter of therepresentablevariant that shipped alongside (v1'sGrate.at, laterMultiFocus.representableAt) — was retired once.at(i)made position a read-time argument: it never reached the built optic, so two calls with different indices were the same optic. See the changelog. - PowerSeries fold —
Snd[A]match-type vestige eliminated;mfAssocPSVecpreserves the parallel-arrayAssocSndZrepresentation and bothMultiFocusSingleton/MultiFocusPSMaybeHitfast-paths verbatim. JMH within ±5% of baseline at every size up to 1024. Seedocs/research/2026-04-29-powerseries-fold-spike.md. - FixedTraversal
[N]fold —Traversal.{two,three,four}rerouted throughMultiFocus[Function1[Int, *]]; the FT-shape gains the inboundIso ↪, outbound↪ ModifyF, and same-carrier.andThenfrom the unified MF carrier — three new compositions the user can write today that pre-fold were all U. Seedocs/research/2026-04-29-fixedtraversal-fold-spike.md.
The current, compiler-pinned composition matrix (11 families, 121
cells) lives in
Optics → Composition matrix; the
historical gap analysis that tracked the fold cell by cell is
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
shipped pre-fold) lives in
docs/research/2026-04-22-alglens-vs-powerseries.md;
post-fold the gap is closed by mfAssocPSVec's parallel-array
specialisation.
Constructors at a glance
// Generic factory — F[A] source, identity rebuild
def apply[F[_], A]: Optic[F[A], F[A], A, A, MultiFocus[F]]
// Polymorphic counterpart — focus type change, the pEach analogue
def pApply[F[_], A, B]: Optic[F[A], F[B], A, B, MultiFocus[F]]
// Cross-carrier lifts — focus is already F[A], inner gets A
def fromLensF[F, S, T, A, B](
lens: Optic[S, T, F[A], F[B], Tuple2]
): Optic[S, T, A, B, MultiFocus[F]]
def fromPrismF[F: MonoidK, S, T, A, B](
prism: Optic[S, T, F[A], F[B], Either]
): Optic[S, T, A, B, MultiFocus[F]]
def fromOptionalF[F: MonoidK, S, T, A, B](
opt: Optic[S, T, F[A], F[B], Affine]
): Optic[S, T, A, B, MultiFocus[F]]
// Absorbed-Grate factories — F = Function1[F.Representation, *]
def representable[F: Representable, A]
: Optic[F[A], F[A], A, A, MultiFocus[Function1[F.Representation, *]]]
// Absorbed-Grate.tuple — F = Function1[Int, *]
def tuple[T <: Tuple, A](using ValueOf[Tuple.Size[T]], Tuple.Union[T] <:< A)
: Optic[T, T, A, A, MultiFocus[Function1[Int, *]]]
// Index witness — what the Iso → MF[Function1[X0, *]] bridge reads at
def at[X0](i: X0): RepresentativeIndex[X0] // `data.RepresentativeIndex.at`
Traversal.each[T, A] and Traversal.{two,three,four} are shipped
in dev.constructive.eo.optics.Traversal and produce
MultiFocus[PSVec] / MultiFocus[Function1[Int, *]] carriers
respectively.
Further reading
- Cookbook → Many focuses at once — the end-to-end recipes that ground the absorbed sub-shapes.
- Concepts → Composition — the carrier graph and the bridge / lattice diagrams.
MultiFocus.scala— the canonical source for the carrier definition, capability traits, and Composer instances; bottom-of-file comment carries the structural-rejection rationale for the directionsMultiFocus → DirectandMultiFocus → Forget[G].
The sub-shapes MultiFocus[F] unifies have a deeper literature:
- Chris Penner's articles on grates, Kaleidoscopes, and algebraic lenses (chrispenner.ca) — the original treatments of the families absorbed here.
- Profunctor optics, a categorical update (Clarke, Elkins, Gibbons, Loregian, Milewski, Pillmore, Román) — the categorical account that places grates, traversals, and algebraic optics in one framework.
- Co-Presheaf Optics (Bartosz Milewski).