Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Removed

- **core: `MultiFocus.representableAt`** — the `repr0` argument was unobservable by
construction. The Grate encoding's focus is the whole bundle `F.Representation => A`
(`X = Unit`) and the write is a pointwise `F.tabulate`, so the index never reached the
built optic and two calls with different indices were the same optic — the factory was
`MultiFocus.representable` plus a parameter nothing could read. It is removed rather
than deprecated, matching the 0.x line's dead-surface policy (see
[`mima.sbt`](./mima.sbt) for the break list): **source- and binary-breaking** for
direct callers. Migrate by dropping the parameter — `MultiFocus.representable[F, A]`
builds the same optic — and read a position with the `.at(i)` extension, which takes
the index per call (`g.at(i)(fa) == F.index(fa)(i)`) and so subsumes the
construction-time one. Pinned by `MultiFocusFunction1Spec` (read / write agreement with
the instance's own `index` and `map` on two `Representable`s, one of them permuted) and
the negative fixture in `UnlawfulFixturesSpec` (a lead-sampling rebuild — the
parameter's only conceivable real semantics — fails `MultiFocusLaws.modifyIdentity`).

## [0.18.0] - 2026-09-29

### Fixed
Expand Down
24 changes: 9 additions & 15 deletions core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,15 @@ object MultiFocusK:
* on `from((_, k))` materialise via `F.tabulate(k)`. The `.modify(f)` round-trip is exactly
* `F.map(fa)(f)`.
*
* Position is a read-time argument, never a property of the optic: the index is supplied per
* call by the `.at(i)` extension (`g.at(i)(fa) == F.index(fa)(i)`), and `Representable` has no
* canonical `Representation` to hand a constructor anyway — `Function1[Boolean, *]` has no
* privileged `Boolean`, and `Function1[Nothing, *]` has no index at all. So this factory takes
* no representative index, and the built optic carries none: `X = Unit`, and the whole
* index-parametric read lives in the focus bundle. (Pre-0.19 a second name,
* `representableAt(F)(repr0)`, took exactly such an index; it built this same optic — see the
* changelog for the removal.)
*
* @group Constructors
*/
def representable[F[_], A](using
Expand Down Expand Up @@ -930,21 +939,6 @@ object MultiFocusK:
def zip[F[_], A, B](fa: F[A], fb: F[B])(using Representable[F]): F[(A, B)] =
zipWith(fa, fb)((a, b) => (a, b))

/** Representable-indexed variant with explicit representative index. The `repr0` argument is
* unused at runtime (rebuild operates pointwise via `F.tabulate`); preserved for API parity and
* to leave the door open for a future `.lead` accessor.
*
* @group Constructors
*/
def representableAt[F[_], A](F: Representable[F])(
repr0: F.Representation
): Optic[F[A], F[A], A, A, MultiFocus[Function1[F.Representation, *]]] =
val _ = repr0
new Optic[F[A], F[A], A, A, MultiFocus[Function1[F.Representation, *]]]:
type X = Unit
def to(fa: F[A]): (Unit, F.Representation => A) = ((), F.index(fa))
def from(pair: (Unit, F.Representation => A)): F[A] = F.tabulate(pair._2)

/** Polymorphic homogeneous-tuple Function1-shaped factory. `to(t) = ((), i => t._i)`,
* `from((_, k))` materialises via `Tuple.fromArray(Array.tabulate(size)(i => k(i)))`.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package dev.constructive.eo

import scala.language.implicitConversions

import cats.Representable
import cats.instances.function.given
import cats.{Functor, Representable}
import dev.constructive.eo.compose.*
import org.scalacheck.Prop.forAll
import org.specs2.ScalaCheck
Expand All @@ -19,7 +19,8 @@ import optics.Optic.*
*
* - `MultiFocus.representable` over a Naperian Function1 (formerly
* `Grate.apply[F: Representable]`)
* - `MultiFocus.representableAt` with explicit lead index (formerly `Grate.at`)
* - the `.at(i)` read surface standing in for the retired `representableAt` / `Grate.at`
* construction-time index, pinned against `F.index` on a permuted `Representable`
* - `MultiFocus.tuple[T <: Tuple, A]` (formerly `Grate.tuple`)
* - `forgetful2multifocusFunction1` Iso → MultiFocus[Function1] bridge (formerly
* `forgetful2grate`)
Expand Down Expand Up @@ -115,31 +116,66 @@ class MultiFocusFunction1Spec extends Specification with ScalaCheck:
idOk && rotOk
}

// covers: MultiFocus.representableAt — explicit-lead variant — modify-at-position and
// replace-broadcast both round-trip through the per-position read;
// MultiFocus[Function1].at(i) — typeclass-gated `.at(i: F.Representation)` read surface
// uses Representable[Function1[X0, *]] to read the focus at a chosen position (Q2 deliverable)
"MultiFocus.representableAt + .at(i): Representable-indexed factory + position read" >> {
// A second Grate `Representable`, over a container whose index ORDER is a permutation of its
// field order — so "index 0" and "first field" are different questions and a privileged / lead
// position has nowhere to hide. Lawful: `Representable` asks for `index` / `tabulate` to be
// mutually inverse, never for the index order to follow the declaration order.
enum Slot:
case S0, S1, S2

case class Tri[A](a: A, b: A, c: A)

given triFunctor: Functor[Tri] with
def map[A, B](fa: Tri[A])(f: A => B): Tri[B] = Tri(f(fa.a), f(fa.b), f(fa.c))

given triRepresentable: Representable.Aux[Tri, Slot] = new Representable[Tri]:
type Representation = Slot
def F: Functor[Tri] = triFunctor

def index[A](fa: Tri[A]): Slot => A =
case Slot.S0 => fa.c
case Slot.S1 => fa.a
case Slot.S2 => fa.b

def tabulate[A](f: Slot => A): Tri[A] = Tri(f(Slot.S1), f(Slot.S2), f(Slot.S0))

// covers: MultiFocus.representable + .at(i) — position is a READ-time argument
// (`g.at(i)(fa) == F.index(fa)(i)`; the typeclass-gated read surface that replaced
// `representableAt`'s construction-time index), pinned against `F.index` / the instance's own
// `map` for two `Representable`s, the second permuted; modify / replace stay pointwise — the
// property a lead-sampling rebuild would break (witnessed negatively by UnlawfulFixturesSpec)
"MultiFocus.representable + .at(i): position read is index-parametric, modify stays pointwise" >> {
val F = summon[Representable[[a] =>> Boolean => a]]
val gTrue: Optic[Boolean => Int, Boolean => Int, Int, Int, MultiFocus[Function1[Boolean, *]]] =
MultiFocus.representableAt[[a] =>> Boolean => a, Int](F)(true)
val gFalse: Optic[Boolean => Int, Boolean => Int, Int, Int, MultiFocus[Function1[Boolean, *]]] =
MultiFocus.representableAt[[a] =>> Boolean => a, Int](F)(false)
val g: Optic[Boolean => Int, Boolean => Int, Int, Int, MultiFocus[Function1[Boolean, *]]] =
MultiFocus.representable[[a] =>> Boolean => a, Int]

val fn: Boolean => Int = b => if b then 42 else 7
val doubled = gTrue.modify(_ * 2)(fn)
val doubled = g.modify(_ * 2)(fn)
val modOk = (doubled(true) === 84).and(doubled(false) === 14)

val fn2: Boolean => Int = b => if b then 1 else 2
val flat = gFalse.replace(99)(fn2)
val flat = g.replace(99)(fn2)
val replOk = (flat(true) === 99).and(flat(false) === 99)

val g: Optic[Boolean => Int, Boolean => Int, Int, Int, MultiFocus[Function1[Boolean, *]]] =
MultiFocus.representable[[a] =>> Boolean => a, Int]
val readFn: Boolean => Int = b => if b then 100 else 200
val readOk = (g.at(true)(readFn) === 100).and(g.at(false)(readFn) === 200)

modOk.and(replOk).and(readOk)
val readOk = (g.at(true)(readFn) === F.index(readFn)(true))
.and(g.at(false)(readFn) === F.index(readFn)(false))

// Permuted instance: the optic must follow the INSTANCE's index order (S0 is the THIRD field),
// and its write must stay pointwise — a rebuild that sampled one index would make every Slot
// equal, so `modify` would stop agreeing with the instance's own `map`.
val T = summon[Representable[Tri]]
val gt: Optic[Tri[Int], Tri[Int], Int, Int, MultiFocus[Function1[Slot, *]]] =
MultiFocus.representable[Tri, Int](using T)
val tri = Tri(1, 2, 3) // S0 -> 3, S1 -> 1, S2 -> 2
val triRead = (gt.at(Slot.S0)(tri) === 3)
.and(gt.at(Slot.S1)(tri) === 1)
.and(gt.at(Slot.S2)(tri) === 2)
val triWrite = (gt.modify(_ * 10)(tri) === triFunctor.map(tri)(_ * 10))
.and(gt.replace(0)(tri) === T.tabulate(_ => 0))
val triOk = triRead.and(triWrite)

modOk.and(replOk).and(readOk).and(triOk)
}

// covers: MultiFocus.tuple .andThen MultiFocus.tuple — same-carrier composition exercises
Expand Down
6 changes: 6 additions & 0 deletions mima.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
// cats-eo-avro has no published baseline anyway. Breaking-change
// history, newest first:
//
// 0.19: core `MultiFocus.representableAt` removed — the `repr0` argument
// never reached the built optic (the factory tabulates pointwise, so
// the index is a read-time argument, not a property of the optic),
// and `MultiFocus.representable` already built the identical carrier.
// Read a position with the `.at(i)` extension instead (source- and
// binary-breaking for direct callers; the replacement is one line).
// 0.17: (a) jsoniter's string-path constructors keep the failure in the type
// — `JsoniterPrism.fromPath` / `JsoniterTraversal.fromPath` return
// `Either[String, _]`, the throwing `fromPath` is gone, and
Expand Down
14 changes: 8 additions & 6 deletions site/docs/multifocus.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ multi-focus job:
|-----------|-----|---------------|
| **`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` / `representableAt`). |
| **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`. |

Expand Down Expand Up @@ -395,10 +395,15 @@ on the worktree branch that landed it:
See [`docs/research/2026-04-28-multifocus-unification.md`](https://github.com/Constructive-Programming/eo/blob/main/docs/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
as `MultiFocus.representable` / `MultiFocus.representableAt` /
`MultiFocus.tuple`. The spike's research doc was lost in
as `MultiFocus.representable` / `MultiFocus.tuple`. The spike's research doc was lost in
consolidation; the surviving evidence is the carrier-doc
comment in `MultiFocus.scala` and the absorbed factory code.
The field's last remnant — the `repr0` parameter of the
`representable` variant that shipped alongside (v1's `Grate.at`,
later `MultiFocus.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;
`mfAssocPSVec` preserves the parallel-array `AssocSndZ`
representation and both `MultiFocusSingleton` /
Expand Down Expand Up @@ -450,9 +455,6 @@ def fromOptionalF[F: MonoidK, S, T, A, B](
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, *]]]
Expand Down
9 changes: 5 additions & 4 deletions site/docs/optics.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,11 @@ ownerAllPhonesMobile.modify(!_)(Owner(List(
`MultiFocus[Function1[X0, *]]` — a uniform rewrite across a fixed
shape: homogeneous tuples and Naperian / representable containers,
where every position is rebuilt the same way. The factories are
`MultiFocus.tuple[T <: Tuple, A]` (homogeneous-tuple uniform rewrite),
`MultiFocus.representable[F: Representable, A]` (arbitrary Naperian
rebuild), and `MultiFocus.representableAt` (representative-index
variant). See [MultiFocus reference](multifocus.md) and
`MultiFocus.tuple[T <: Tuple, A]` (homogeneous-tuple uniform rewrite)
and `MultiFocus.representable[F: Representable, A]` (arbitrary
Naperian rebuild); a read lands on a chosen position with the
`.at(i)` extension, which takes the `Representable` index per call.
See [MultiFocus reference](multifocus.md) and
[Cookbook → Recipe A](cookbook.md) for a worked example.

`MultiFocus.zipWith(fa, fb)(f)` (and its pairing form `zip`) is the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package dev.constructive.eo

import cats.instances.function.given
import cats.{Functor, Representable}
import org.specs2.mutable.Specification

import optics.{AffineFold, PickFold}
import data.ModifyF
import optics.{AffineFold, Optic, PickFold}
import data.{ModifyF, MultiFocus}
import forgetful.ForgetfulFunctor
import laws.AffineFoldLaws
import laws.{AffineFoldLaws, MultiFocusLaws}
import laws.data.ModifyFLaws

/** Negative fixtures: deliberately UNLAWFUL instances asserted to FAIL specific laws.
Expand All @@ -19,6 +21,11 @@ import laws.data.ModifyFLaws
* fails this spec.
*
* See site/docs/quality-assurance.md ("Mutation testing" caveats) for the full story.
*
* The last fixture is a *semantic* one rather than a mutation-killing one: it pins the rebuild
* shape a retired API parameter could only have expressed (sample ONE written focus at a lead
* index, rebuild every position from it), so the surviving pointwise-tabulation property is known
* to discriminate that shape rather than merely to pass.
*/
class UnlawfulFixturesSpec extends Specification:

Expand Down Expand Up @@ -66,3 +73,52 @@ class UnlawfulFixturesSpec extends Specification:
val laws = new ModifyFLaws[(Int, String), Int] {}
laws.functorComposition(1, _.length, _ + 1, _ + 1, "abc") must beFalse
}

"MultiFocusLaws.modifyIdentity rejects a lead-sampling rebuild" >> {
// covers: laws/MultiFocusLaws.scala modifyIdentity against a `from` that samples ONE written
// focus at a chosen "lead" index and rebuilds every position from it — the only semantics the
// retired `MultiFocus.representableAt(F)(repr0)` parameter could have had. A lead rebuild
// collapses the container to a constant, so the law must return false for it and true for
// the shipped pointwise `MultiFocus.representable` on the same carrier, same input.
//
// The carrier's `S` is a STRUCTURAL container here, not `Function1[X0, *]`: the law
// compares with `==`, which is reference equality on a function, so a function-typed `S`
// would fail this law whatever the rebuild shape (that is also why the MF discipline
// fixtures register List / ZipList / Const rather than the Grate carrier).
case class Dup[A](a: A, b: A)
given dupFunctor: Functor[Dup] with
def map[A, B](fa: Dup[A])(f: A => B): Dup[B] = Dup(f(fa.a), f(fa.b))
given dupRepresentable: Representable.Aux[Dup, Boolean] = new Representable[Dup]:
type Representation = Boolean
def F: Functor[Dup] = dupFunctor
def index[A](fa: Dup[A]): Boolean => A = if _ then fa.a else fa.b
def tabulate[A](f: Boolean => A): Dup[A] = Dup(f(true), f(false))

val leadSampling: Optic[Dup[Int], Dup[Int], Int, Int, MultiFocus[Function1[Boolean, *]]] =
new Optic[Dup[Int], Dup[Int], Int, Int, MultiFocus[Function1[Boolean, *]]]:
type X = Unit
def to(fa: Dup[Int]): MultiFocus[Function1[Boolean, *]][X, Int] =
MultiFocus((), dupRepresentable.index(fa))
def from(b: MultiFocus[Function1[Boolean, *]][X, Int]): Dup[Int] =
val lead: Boolean = true // the "representative index"; sampled for the whole rebuild
Dup(b.foci(lead), b.foci(lead))
val lawful: Optic[Dup[Int], Dup[Int], Int, Int, MultiFocus[Function1[Boolean, *]]] =
MultiFocus.representable[Dup, Int]

// Named outside the law body so the instance's own `functor` member cannot take part in its
// own initialisation.
val boolFnFunctor: Functor[Function1[Boolean, *]] = summon[Functor[Function1[Boolean, *]]]
def lawsFor(
o: Optic[Dup[Int], Dup[Int], Int, Int, MultiFocus[Function1[Boolean, *]]]
) = new MultiFocusLaws[Dup[Int], Int, Function1[Boolean, *]]:
given functor: Functor[Function1[Boolean, *]] = boolFnFunctor
def multiFocus = o

(lawsFor(leadSampling).modifyIdentity(Dup(1, 2)) must beFalse)
.and(lawsFor(lawful).modifyIdentity(Dup(1, 2)) must beTrue)
// The pin `MultiFocusFunction1Spec` holds the shipped factory to — that a write agrees
// with the carrier instance's own `map` — discriminates the same shape.
.and(
(leadSampling.modify(_ * 10)(Dup(1, 2)) == dupFunctor.map(Dup(1, 2))(_ * 10)) must beFalse
)
}
Loading