MultiFocus

MultiFocus[F][X, A] = (X, F[A]) — a structural leftover paired with an F-shaped focus. One carrier, classifier-shaped through the type parameter F, that absorbed five separate carriers in the lead-up to 0.1.0. This page is the consolidated reference: the unification narrative, the typeclass-gated capability matrix, the composability profile, and the historical landmarks.

For the mechanical intro see Optics → MultiFocus; for runnable patterns the Cookbook ships three end-to-end recipes that exercise the prototypical Grate / Kaleidoscope / PowerSeries-downstream shapes.

The unification narrative

Five carriers — AlgLens[F], Kaleidoscope, Grate, PowerSeries, FixedTraversal[N] — each pairing a structural leftover with some flavour of focus container, all collapse pre-0.1.0 into the single MultiFocus[F] carrier. Each former carrier is now a sub-shape selected by the choice of F:

Former carrier Post-fold shape Notes
AlgLens[F] MultiFocus[F] for F: Functor / Foldable / Traverse Direct rename. F-as-parameter encoding survived verbatim.
Kaleidoscope MultiFocus[F] for F: Apply aggregates The path-dependent FCarrier member became a plain type parameter; Reflector[F] was deleted in favour of two extension methods (.collectMap, .collectList) — see Q1 below.
Grate MultiFocus[Function1[X0, *]] The (A, X => A) pair collapsed into (Unit, F.Representation => A); the lead-position field was empirically dead (see Q1 of the Grate fold — 20% perf win on .modify from dropping it).
PowerSeries MultiFocus[PSVec] The (Snd[A], PSVec[B]) shape lost its Snd match-type vestige and gained the same-carrier mfAssocPSVec AssociativeFunctor instance. The parallel-array AssocSndZ representation survived inside the PSVec specialisation; both MultiFocusSingleton (AlwaysHit) and MultiFocusPSMaybeHit (Prism / Optional) fast-paths are preserved.
FixedTraversal[N] MultiFocus[Function1[Int, *]] Traversal.{two,three,four} factories now produce the absorbed-Grate sub-shape over an Int => A lookup; same carrier as MultiFocus.tuple.

The empirical justification lives in four research spike documents referenced under Historical landmarks below. Two headline numbers from those spikes:

The pre-fold split was accidental complexity. Each carrier had been introduced for a real semantic distinction at construction time, but the runtime shape — pair of leftover and focus container — was identical in every case. Lifting F to a plain type parameter and letting the cats hierarchy supply Functor / Foldable / Traverse on demand collapsed the surface without losing any capability.

The general flexibility win

MultiFocus[F][X, A] = (X, F[A]) 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 five-carrier collapse is the demonstration: each former carrier was just a different F plugged into the same shape. The PSVec case adds a hand-tuned same-carrier specialisation (mfAssocPSVec) for perf, but the 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}

.modifyFunctor[F]

val listMF = MultiFocus.apply[List, Int]
listMF.modify(_ + 1)(List(1, 2, 3))
// res0: 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.

.foldMapFoldable[F]

listMF.foldMap(identity[Int])(List(1, 2, 3, 4))
// res1: 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.

.modifyATraverse[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))
// res2: Option[List[Double]] = Some(List(1.0, 0.5, 0.25))
doubleMF.modifyA[Option](safeRecip)(List(1.0, 0.0, 4.0))
// res3: 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))
)
// res4: 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.

.collectList — List-only cartesian collapse

listMF.collectList(_.sum)(List(1, 2, 3, 4))
// res5: 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$17113/0x00007fcc026b0440@76eca12a
grateF.at(true)(payment)
// res6: Int = 100
grateF.at(false)(payment)
// res7: 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.

Composability profile

MultiFocus[F] has shipped inbound bridges from every classical read-write family (conditional on F's typeclass set) and a single outbound bridge to SetterF. Every other outbound direction is 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 (none — Function1 carrier) Direct broadcast; lights up Iso → Traversal.{two,three,four} and Iso → MultiFocus.representable / tuple.
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:

Outbound — only to SetterF

import dev.constructive.eo.Composer
import dev.constructive.eo.data.SetterF

val setter = summon[Composer[MultiFocus[List], SetterF]].to(listMF)
setter.modify(_ * 2)(List(1, 2, 3))
// res8: List[Int] = List(2, 4, 6)

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: SetterF lacks AssociativeFunctor by design. The morph value lives at the morph site, not at the chain site.

Composition limits

Two further outbound directions are structurally rejected rather than absent. The rationale lives at the bottom of MultiFocus.scala and in docs/research/2026-04-23-composition-gap-analysis.md §3.2.6:

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 carries no constraint — it's the only inbound for the absorbed-Grate sub-shape — so chains of the form iso.andThen(MultiFocus.tuple[...]) and iso.andThen(Traversal.two(...)) work, but lens.andThen(grate) does not.

Worked examples

Three end-to-end recipes in the Cookbook cover the prototypical post-fold shapes:

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:

The composition gap analysis tracks the matrix collapse cell by cell: 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]]

// 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, *]]]

def representableAt[F, A](F: Representable[F])(repr0: F.Representation)
  : 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, *]]]

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