diff --git a/.gitignore b/.gitignore index 83095a81..3e830fa4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ !.claude/skills/ # Compound-engineering tooling scratch (ce-review summaries, etc.) .context/ +# Lavish Editor review artifacts (agent-generated HTML review surfaces) +.lavish/ .envrc .envrc.local .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index db5da47e..3838898d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`cats-eo`: `data.RepresentativeIndex[X0]` — the Grate bridge's index becomes a real value.** + A `MultiFocus[Function1[X0, *]]` bundle is an `X0 => A`, and one place in the library has to read + a bundle it did not build: the `Direct → MultiFocus[Function1[X0, *]]` bridge's product + (`Function1BroadcastOptic.from`), whose carrier stores only `Unit`. That read was a + `null.asInstanceOf[X0]` sentinel guarded by a documented constant-bundle contract; it is now a + caller-supplied index. `RepresentativeIndex` ships canonical instances for the index types the + Grate factories fix with one (`Int` → `0` for `MultiFocus.tuple` and `apply` over + `Function1[Int, *]`, `Boolean` → `false`, `Unit` → `()`) plus any singleton type via `ValueOf`, and + `RepresentativeIndex.at(i)` for everything else. + +### Changed + +- **`cats-eo`: `forgetful2multifocusFunction1` now asks for a `RepresentativeIndex[X0]`** (source- + breaking for index types with no instance). `iso.andThen(MultiFocus.tuple[...])` and the other + `Iso → MultiFocus[Function1[...]]` chains are unaffected — the witness resolves off + `RepresentativeIndex`'s companion with no import — but a grate over an algebraic index type now + needs `given RepresentativeIndex[X] = RepresentativeIndex.at(v)` in scope, and an uninhabited + index type (`Function1[Nothing, *]`, a phantom slot) no longer bridges at all: there is no index + to witness, so the read that has no answer is refused instead of forged. Nothing observable + changes on the composition paths — the witness is read only by a bridged optic's own `from`, which + every shipped path calls with a constant bundle, and `grate ∘ iso` still rebuilds per index + through `broadcastFrom`. The two remaining `unobserved` stand-ins are existential leftovers (an + inner optic's leftover in `mfAssocFunction1`'s bundle branch, `collectList`'s own) and are + documented as such; neither is an index, and neither can be reached by a single value. + Rationale, measurements and the alternative designs: + [`docs/research/2026-09-30-grate-witness-index.md`](./docs/research/2026-09-30-grate-witness-index.md). + ### Removed - **core: `MultiFocus.representableAt`** — the `repr0` argument was unobservable by diff --git a/CLAUDE.md b/CLAUDE.md index b8c8e77d..f8f39344 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Test-only: `org.typelevel:discipline-specs2_3:2.0.0`. | Module | Directory | Artifact | Purpose | |--------|-----------|----------|---------| -| `core` | `core/` | `cats-eo` | Hand-written optics and data structures; cats-typeclass bridges live as CONSTRUCTORS on the optic companions, not givens (`Traversal.each`/`first`/`second`/`both`, `Modify.functor`, `Fold[F, A]`, `Lens.representable(r)` positional Lens, `MultiFocus.representable` grate) — clients declare their own givens (optic given or direct SAM `Can*` instance), since multiple lawful optics per `(F[A], A)` pair rule out a canonical one | +| `core` | `core/` | `cats-eo` | Hand-written optics and data structures; cats-typeclass bridges live as CONSTRUCTORS on the optic companions, not givens (`Traversal.each`/`first`/`second`/`both`, `Modify.functor`, `Fold[F, A]`, `Lens.representable(r)` positional Lens, `MultiFocus.representable` grate) — clients declare their own givens (optic given or direct SAM `Can*` instance), since multiple lawful optics per `(F[A], A)` pair rule out a canonical one. One shipped bridge DOES gate on a given: `Composer[Direct, MultiFocus[Function1[X0, *]]]` (`forgetful2multifocusFunction1`) needs a `data.RepresentativeIndex[X0]` — the index its product's own `from` reads a bundle at. `Int` / `Boolean` / `Unit` / singletons resolve off the companion (import-free), any other index type needs `given RepresentativeIndex[X] = RepresentativeIndex.at(v)` in scope, and an uninhabited one does not bridge — see `docs/research/2026-09-30-grate-witness-index.md` | | `laws` | `laws/` | `cats-eo-laws` | Discipline-style law definitions (reusable by downstream projects) | | `tests` | `tests/` | — (not published) | Law-based and behavioural test suites | | `generics` | `generics/` | `cats-eo-generics` | Auto-derivation of Lens/Prism via Scala 3 quoted macros | 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 c1c25fc7..0302f9bd 100644 --- a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala +++ b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala @@ -67,6 +67,78 @@ private[eo] trait MultiFocusSingleton[S, T, A, B, X0]: ysBuf.unsafeAppend(x) flatBuf.unsafeAppend(a) +/** Stand-in for a value of a type that the `Function1` kernel never lets anyone observe. Two call + * sites need one, and both stand in for an **existential leftover the write path discards** rather + * than for something anyone could have computed: + * + * - the **inner optic's leftover** in `mfAssocFunction1` — the kernel threads the OUTER's + * leftover through `Z`, so the inner's has no producer on the write path; every shipped + * `MultiFocus[Function1]` optic ignores its leftover on the write side, which is what makes + * that direction bundle-level. + * - the **optic's own leftover** in `collectList` — the aggregation builds a fresh singleton + * `List(b)` and never sees the leftover it discards. + * + * Neither is an **index**: an index into a broadcast bundle is now a real value, supplied by + * [[RepresentativeIndex]] and stored on [[Function1BroadcastOptic]], because that read *is* + * observable and the index has to come from somewhere. What is left here is per-position data + * whose only producer is the read side — an index witness cannot stand in for it, and neither can + * any single value, since a leftover may differ at every index. + * + * `inline` is load-bearing: `null.asInstanceOf[Int]` is `0` at a call site with a primitive + * leftover type, while a non-inlined generic method would box and unbox the `null` into an NPE. + * The invariant is "unobserved", not "unobservable" — if a future F1 optic observes its own + * leftover on `from`, this is the line to revisit (see + * `docs/research/2026-04-23-code-quality-review.md`, finding 4). + */ +private[eo] inline def unobserved[A]: A = null.asInstanceOf[A] + +/** The `Direct → MultiFocus[Function1[X0, *]]` bridge's product — a constant-broadcast optic whose + * bundle is one focus, broadcast to every index ([[MultiFocusK.forgetful2multifocusFunction1]]). + * + * The type is itself the witness `mfAssocFunction1` matches on, because the carrier alone cannot + * say how a write bundle should be read. THIS optic has exactly one value to build, so a bundle + * has to be sampled position by position to stay positional — while a *bundle* carrier (e.g. + * `MultiFocus.apply` as the inner) consumes the whole bundle once. + * + * Only the write half needs a hook for that. The read half does not: `to` broadcasts the single + * focus across the whole index space, so the kernel can simply read the bundle at the index it + * wants. [[broadcastFrom]] exists because the write direction has no such trick — one value in, + * one value out can only be expressed per index. + * + * The `at` index is the same story from the other side: this optic's own `from` must read a bundle + * it did not build, so it is given a real index at construction ([[RepresentativeIndex]]) rather + * than inventing one. `at` is read there and nowhere else. + * + * A `final class` storing the source optic directly — NOT an abstract member pair — for the same + * composed-dispatch reason documented on [[optics.Getter]] / [[optics.Review]]. + */ +final private[eo] class Function1BroadcastOptic[S, T, A, B, X0]( + o: Optic[S, T, A, B, Direct], + at: X0, +) extends Optic[S, T, A, B, MultiFocus[Function1[X0, *]]]: + // `Unit`: a broadcast read has no leftover to hand the rebuild — the new focus travels in the + // bundle (the carrier's second component), and there is no miss to pass through. `Nothing` (the + // `Getter` / `Review` / `Unfold` choice) is unavailable: `from` must really build a `T`. + type X = Unit + + /** Build the target from a single written focus — the write half of the broadcast, one value per + * position. The kernel's per-index write path. + */ + def broadcastFrom(b: B): T = o.from(Direct(b)) + + def to(s: S): MultiFocus[Function1[X0, *]][X, A] = MultiFocus((), (_: X0) => o.to(s).value) + + /** '''Constant-bundle contract.''' `at` is correct because every bundle that reaches this method + * on a shipped path is constant: `modify` / `replace` / `collect*` map this optic's own + * broadcast, and `mfAssocFunction1`'s bundle branch hands a `.from` its rebuild as a constant + * function. The kernel does NOT come through here at all — `mfAssocFunction1` recognises the + * product and writes per index through [[broadcastFrom]]. Given a *varying* bundle by hand, this + * reads position `at` — a defined answer, not a forged one, and the caller's to choose through + * [[RepresentativeIndex]]. + */ + def from(fb: MultiFocus[Function1[X0, *]][X, B]): T = + broadcastFrom(MultiFocusK.foci(fb)(at)) + /** Per-F O(n) builder. Carried as a typeclass because `MonoidK[F].combineK` has inconsistent * asymptotics across F (O(n²) on Vector, lossy on Option), so deriving `fromList` from * `Traverse[F] + MonoidK[F]` is asymptotically wrong on the carriers we care about. @@ -292,36 +364,50 @@ object MultiFocusK: /** Function1-shaped same-carrier composition — the grate-absorbed case. The general [[mfAssoc]] * requires `Traverse[F]` + `MultiFocusFromList[F]`; `Function1[X0, *]` admits neither, so this - * instance composes the rebuild closures directly. The outer rebuild is a constant broadcast — - * sound because every shipped outer (iso-morphed, tuple-built) rebuilds via broadcast anyway. + * instance composes the rebuild closures directly. + * + * The READ side needs no special case: it reads the inner's bundle at the index the outer's + * element came from (`kC(i) = inner.to(kO(i))._2(i)`), which a [[Function1BroadcastOptic]] + * answers with its broadcast focus and a bundle carrier with element `i` — so element `i` is + * read at `i` in both shapes rather than element 0's bundle being reused. + * + * The WRITE side does need one, because the two shapes disagree about what a bundle means. A + * broadcast inner builds exactly ONE value, so it is written per index (`kB(i) = + * inner.broadcastFrom(kD(i))`); handing it the per-position bundle through `from` would sample + * one index and rebuild every position from that single value — the collapse `grate ∘ iso` used + * to have. A bundle inner's `from` is a bundle-level rebuild, so it consumes the whole write + * bundle once and the outer receives the constant rebuild of that single result. + * * See `docs/research/2026-04-29-fixedtraversal-fold-spike.md`. * * @group Instances */ given mfAssocFunction1[X0, Xo, Xi]: AssociativeFunctor[MultiFocus[Function1[X0, *]], Xo, Xi] with - // The broadcast carrier's composed context is never read (both `composeFrom` destructures - // discard it), so it is Unit BY TYPE — no null-sentinel pair to cast. - type Z = Unit + // `Z = Xo`: the kernel threads the OUTER's leftover from `composeTo` into `composeFrom`, so + // the outer's own `from` gets the value its own `to` produced — no stand-in needed. The + // inner's leftover has no producer on this path; see [[unobserved]]. + type Z = Xo def composeTo[S, T, A, B, C, D]( s: S, outer: Optic[S, T, A, B, MultiFocus[Function1[X0, *]]] { type X = Xo }, inner: Optic[A, B, C, D, MultiFocus[Function1[X0, *]]] { type X = Xi }, ): MultiFocus[Function1[X0, *]][Z, C] = - val (_, kO) = outer.to(s) - // Null sentinel works because `.modify` doesn't observe the focus value (spike Q1). - val a: A = kO(null.asInstanceOf[X0]) - val (_, kI) = inner.to(a) - ((), kI) + val (xo, kO) = outer.to(s) + (xo, (i: X0) => inner.to(kO(i))._2(i)) def composeFrom[S, T, A, B, C, D]( xd: MultiFocus[Function1[X0, *]][Z, D], inner: Optic[A, B, C, D, MultiFocus[Function1[X0, *]]] { type X = Xi }, outer: Optic[S, T, A, B, MultiFocus[Function1[X0, *]]] { type X = Xo }, ): T = - val (_, kD) = xd - val b: B = inner.from((null.asInstanceOf[Xi], kD)) - outer.from((null.asInstanceOf[Xo], (_: X0) => b)) + val (xo, kD) = xd + inner match + case bc: Function1BroadcastOptic[A, B, C, D, X0] @unchecked => + outer.from((xo, (i: X0) => bc.broadcastFrom(kD(i)))) + case _ => + val b: B = inner.from((unobserved[Xi], kD)) + outer.from((xo, (_: X0) => b)) /** PSVec-specialised same-carrier composition. Where the generic [[mfAssoc]] body builds two * intermediate List accumulators + materialises via `fromList`, this body writes directly into @@ -514,11 +600,12 @@ object MultiFocusK: def collectList(agg: List[A] => B)(using ev: S =:= List[A], ev2: T =:= List[B]): S => T = val _ = (ev, ev2) - // Cartesian / singleton — T = List[B] preserved via List(b). + // Cartesian / singleton — T = List[B] preserved via List(b). The leftover is not threaded by + // this aggregation (the rebuilt optic ignores it); see [[unobserved]]. (s: S) => val (_, fa) = o.to(s) val b: B = agg(fa) - o.from((null.asInstanceOf[o.X], List(b))) + o.from((unobserved[o.X], List(b))) /** Functor-broadcast aggregation — preserves F-shape via `map(_ => agg(fa))`; every focus * position receives the aggregate. Works for any `Functor[F]`; for List this is the @@ -898,7 +985,9 @@ object MultiFocusK: * 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.) + * changelog for the removal.) The one reader that cannot take its index per call — the inbound + * Iso bridge's own `from`, which is handed the whole bundle by the kernel and has no caller to + * ask — is given a [[RepresentativeIndex]] at construction instead (see that type's doc). * * @group Constructors */ @@ -974,23 +1063,34 @@ object MultiFocusK: /** Iso ↪ MultiFocus[Function1[X0, *]] — the Iso side of the grate-shaped surface. Iso's forward * `to: S => A` is broadcast to the constant rebuild `_ => a`; the reverse reads the rebuild at - * any X0 (null sentinel — sound because no shipped rebuild observes its argument; see the - * fixedtraversal-fold spike doc). + * the [[RepresentativeIndex]] witness the bridge was built with. + * + * '''Constant-bundle contract.''' The witness read is sound only while the bundle it is handed + * is constant — true for direct use (`modify` / `replace` map a constant rebuild, so the + * read-at-`at` and the written value agree) and for `mfAssocFunction1`'s broadcast branch, which + * routes a singleton bundle per position. It is NOT sound for an arbitrary varying bundle, which + * is why the product is a [[Function1BroadcastOptic]]: the kernel recognises it and composes the + * write per index (`broadcastFrom`) rather than passing a per-position bundle through `from`. + * The read side needs no such hook — `to` already broadcasts the single focus, so the kernel + * just reads the bundle at the index it wants. See the fixedtraversal-fold spike doc. + * + * The `using` clause is the cost side of that contract: the index has to come from the caller's + * scope, because this `Composer` has none to give and a forged index is exactly what the witness + * removes. Index types with a canonical value (`Int`, `Boolean`, `Unit`, singletons) resolve + * import-free off [[RepresentativeIndex]]; anything else needs a local `given` or an explicit + * `RepresentativeIndex.at`. An uninhabited index type gets no instance — the bridge refuses + * rather than reading a bundle at a value that cannot exist. * * @group Instances */ - given forgetful2multifocusFunction1[X0]: Composer[Direct, MultiFocus[Function1[X0, *]]] with + given forgetful2multifocusFunction1[X0](using + ri: RepresentativeIndex[X0] + ): Composer[Direct, MultiFocus[Function1[X0, *]]] with def to[S, T, A, B]( o: Optic[S, T, A, B, Direct] ): Optic[S, T, A, B, MultiFocus[Function1[X0, *]]] = - new Optic[S, T, A, B, MultiFocus[Function1[X0, *]]]: - type X = Unit - def to(s: S): (Unit, X0 => A) = - val a = o.to(s).value - ((), (_: X0) => a) - def from(pair: (Unit, X0 => B)): T = - o.from(Direct(pair._2(null.asInstanceOf[X0]))) + new Function1BroadcastOptic[S, T, A, B, X0](o, ri.index) /** Reinterpret an Optional whose focus is an `F[A]` as a MultiFocus optic over the elements — the * mirror of [[fromPrismF]] over the `Affine` miss / hit split (miss recycled covariantly, both diff --git a/core/src/main/scala/dev/constructive/eo/data/RepresentativeIndex.scala b/core/src/main/scala/dev/constructive/eo/data/RepresentativeIndex.scala new file mode 100644 index 00000000..302c1b0a --- /dev/null +++ b/core/src/main/scala/dev/constructive/eo/data/RepresentativeIndex.scala @@ -0,0 +1,67 @@ +package dev.constructive.eo +package data + +/** A concrete index value for the `Function1[X0, *]` (Grate) carrier — the witness that makes a + * bundle read *real* instead of forged. + * + * '''Why a witness exists at all.''' A Grate bundle is a function `X0 => A`; reading it needs an + * `X0`, and no rule of the type system produces one. Exactly one site in the library needs that + * read: the `from` of the bridge's product (`Function1BroadcastOptic`, `private[eo]`), which must + * turn a written `MultiFocus[Function1[X0, *]][Unit, B]` back into a `T`, and whose own carrier + * stores only `Unit`. Every other read in the Grate surface is *handed* its index by the caller + * (`to(s)`, the `.at(i)` extension, `F.index`), and every write walks the index space itself + * ([[MultiFocusK.tuple]] counts `0..size-1`, `representable` tabulates). So this typeclass is the + * whole index-supply story, and it is consulted in exactly one place. + * + * '''What the choice of index does — and does not — affect.''' Instances are read on a path whose + * bundle is constant by construction (see that optic's `from`), where every index yields the same + * value: this witness changes *which* value is read only when someone hands `from` a varying + * bundle by hand. It never changes `.modify` / `.replace` / `collect*`, and it never changes a + * `grate ∘ iso` composition — the kernel rebuilds those per index through `broadcastFrom`, which + * needs no index. + * + * '''Supply.''' The shipped instances name the canonical first index of the index types the Grate + * factories fix: `Int` for [[MultiFocusK.tuple]] (and for `apply` over a `Function1[Int, *]`), + * `Boolean`, `Unit`, plus any singleton type via `ValueOf`. For every other index type the caller + * supplies one — either a local `given` (`given RepresentativeIndex[Symbol] = + * RepresentativeIndex.at(Symbol("x"))`) or the [[RepresentativeIndex$.at at]] smart constructor at + * the use site. [[MultiFocusK.representable]] pins its index to `F.Representation`, which has no + * canonical inhabitant — so a grate over one takes its witness from the caller, the same value the + * read side passes to `.at(i)` per call. + * + * '''The shipped instances do privilege a value''' (`0` for `Int`, `false` for `Boolean`) — the + * thing [[MultiFocusK.representable]]'s doc rules out for a *constructor* index, and rightly: an + * index that lives on the optic is a claim about the optic. This one is not that. It is the + * position a read falls back to when nobody is there to name one, it keeps the bridge implicit (so + * `iso.andThen(grate)` stays import-free), and it is unobservable on every shipped path — which is + * why shipping it is a convenience rather than a semantics. The alternative the design note prices + * out is no shipped instances at all: three composition cells go red and every caller writes a + * `given`. + * + * An '''uninhabited''' index type (`Function1[Nothing, *]`-indexed, a phantom slot) gets no + * instance, deliberately: there is no index to witness, so the bridge refuses rather than reading + * a bundle at a value that cannot exist. + */ +trait RepresentativeIndex[X0]: + def index: X0 + +object RepresentativeIndex: + + /** Explicit witness — the construction path for index types with no canonical value. */ + def at[X0](i: X0): RepresentativeIndex[X0] = new RepresentativeIndex[X0]: + def index: X0 = i + + /** Canonical index for an `Int`-indexed Grate ([[MultiFocusK.tuple]]'s index space). */ + given int: RepresentativeIndex[Int] = at(0) + + /** Canonical index for a `Boolean`-indexed Grate (`Function1[Boolean, *]`, the two-point Naperian + * shape `MultiFocus.representable[[a] =>> Boolean => a, _]` produces). + */ + given boolean: RepresentativeIndex[Boolean] = at(false) + + /** The one-inhabitant index space: `Unit` has no other index to pick. */ + given unit: RepresentativeIndex[Unit] = at(()) + + /** Singleton index types — the compiler knows the value, so no canonical choice has to be made. + */ + given singleton[X0 <: Singleton](using v: ValueOf[X0]): RepresentativeIndex[X0] = at(v.value) diff --git a/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala b/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala index bf6ef0b6..775b59b6 100644 --- a/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala +++ b/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala @@ -1,5 +1,6 @@ package dev.constructive.eo +import scala.compiletime.testing.typeChecks import scala.language.implicitConversions import cats.instances.function.given @@ -9,11 +10,55 @@ import org.scalacheck.Prop.forAll import org.specs2.ScalaCheck import org.specs2.mutable.Specification -import data.MultiFocus +import data.{Direct, Function1BroadcastOptic, MultiFocus, RepresentativeIndex} import data.MultiFocus.at -import optics.Optic +import optics.{Iso, Optic} import optics.Optic.* +/** Fixtures for the [[RepresentativeIndex]] blocks — the two index types the shipped instances do + * not cover, one witnessed and one not (`Side`/`Phantom`; the spec's own `Slot`, which #123 made + * the read-time index of `Tri`, is witnessed on the class instead). Same shape as + * `GrateShapeSpec`'s fixture object: the `typeChecks` cells need the names in the *typechecking* + * scope, not a nested block's. + */ +object WitnessFixtures: + + /** An algebraic index type — no canonical inhabitant, so the caller has to witness it. */ + sealed trait Side + case object LeftSide extends Side + case object RightSide extends Side + + given RepresentativeIndex[Side] = RepresentativeIndex.at(RightSide) + + /** Same carrier shape, same factories, **no** witness — the bridge must not resolve. */ + sealed trait Phantom + + type SideFn = [a] =>> Side => a + type PhantomFn = [a] =>> Phantom => a + type BoolFn = [a] =>> Boolean => a + + val grateSide: Optic[Side => Int, Side => Int, Int, Int, MultiFocus[Function1[Side, *]]] = + MultiFocus.representable[SideFn, Int] + + val grateBool + : Optic[Boolean => Int, Boolean => Int, Int, Int, MultiFocus[Function1[Boolean, *]]] = + MultiFocus.representable[BoolFn, Int] + + val gratePhantom + : Optic[Phantom => Int, Phantom => Int, Int, Int, MultiFocus[Function1[Phantom, *]]] = + MultiFocus.representable[PhantomFn, Int] + + val isoInt: Iso[Int, Int] = Iso[Int, Int, Int, Int](_ + 1000, _ - 1000) + + val isoBoolFocus: Iso[Boolean => Int, Boolean => Int] = + Iso[Boolean => Int, Boolean => Int, Boolean => Int, Boolean => Int](identity, identity) + + val isoSideFocus: Iso[Side => Int, Side => Int] = + Iso[Side => Int, Side => Int, Side => Int, Side => Int](identity, identity) + + val isoPhantomFocus: Iso[Phantom => Int, Phantom => Int] = + Iso[Phantom => Int, Phantom => Int, Phantom => Int, Phantom => Int](identity, identity) + /** In-core smoke spec for the absorbed-Grate paths through `MultiFocus[Function1[X0, *]]`. Pins * down the v1 Grate use cases on the unified carrier: * @@ -25,14 +70,21 @@ import optics.Optic.* * - `forgetful2multifocusFunction1` Iso → MultiFocus[Function1] bridge (formerly * `forgetful2grate`) * - The new typeclass-gated `.at(i: F.Representation)` extension method (Q2 surface) - * - Same-carrier `.andThen(grate)` exercising `mfAssocFunction1.composeFrom` (formerly - * `grateAssoc.composeFrom`) + * - The `mfAssocFunction1` composition rules: the broadcast branch (an Iso inner, `grate ∘ iso`) + * rewrites every position from its own focus, the bundle branch (`iso ∘ grate`) takes the + * whole rebuild. See the two composition blocks below. + * - The `RepresentativeIndex` witness: the bridged `from` reads at a supplied index, the + * kernel's per-index write ignores it, and an index type with no instance does not bridge at + * all. * * Replaces the deleted `GrateSpec` + `GrateCoverageSpec`. The block count is preserved 1:1 so the * top-level spec count doesn't regress. */ class MultiFocusFunction1Spec extends Specification with ScalaCheck: + /** Fixture for the non-tuple Grate sources: a function of a boxed focus. */ + case class Box[A](a: A) + // covers: modify applies the function pointwise at every slot (Function1 shape), // replace broadcasts the constant to every slot, modify identity is identity (G1), // modify composes (G2) @@ -92,7 +144,6 @@ class MultiFocusFunction1Spec extends Specification with ScalaCheck: // iso.andThen(MultiFocus.tuple) with non-trivial bijection. Exercises the absorbed // forgetful2grate via `forgetful2multifocusFunction1`. "Composer[Direct, MultiFocus[Function1[Int, *]]]: identity iso and bijection compose cleanly" >> { - import optics.Iso val triple = MultiFocus.tuple[(Int, Int, Int), Int] val idIso = Iso[(Int, Int, Int), (Int, Int, Int), (Int, Int, Int), (Int, Int, Int)]( @@ -139,6 +190,12 @@ class MultiFocusFunction1Spec extends Specification with ScalaCheck: def tabulate[A](f: Slot => A): Tri[A] = Tri(f(Slot.S1), f(Slot.S2), f(Slot.S0)) + // The witness the inbound Iso bridge needs for that same `Slot`-indexed carrier — the index #123 + // made a read-time argument is the one the BRIDGE cannot take per call (the kernel hands it the + // whole bundle), so here it is a construction-time value instead: S1, the SECOND index, not the + // first field. Additive: nothing in this spec built a `Slot`-indexed bridge before it. + given slotWitness: RepresentativeIndex[Slot] = RepresentativeIndex.at(Slot.S1) + // 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 @@ -178,11 +235,183 @@ class MultiFocusFunction1Spec extends Specification with ScalaCheck: modOk.and(replOk).and(readOk).and(triOk) } - // covers: MultiFocus.tuple .andThen MultiFocus.tuple — same-carrier composition exercises - // mfAssocFunction1.composeFrom (the absorbed grateAssoc.composeFrom). - "MultiFocus.tuple.andThen(MultiFocus.tuple) — same-carrier composition through mfAssocFunction1" >> { + // covers: the mfAssocFunction1 BROADCAST branch — `grate ∘ iso` must rewrite every position from + // its own written focus. Regression for the pre-fix collapse, which sampled the outer's rebuild + // once and broadcast the result: read of a doubled (10,20,30) gave 20,20,20 (not 20,40,60) and + // `modify(_ + 1)` gave (11,11,11). `tuple` / `representable` / `apply` are the three shipped + // tabulating Grate factories — all three are pinned here because all three were affected. + "grate ∘ iso positions: tuple / representable / apply rebuild each slot from its own focus" >> { + val shift = Iso[Int, Int, Int, Int](_ + 1000, _ - 1000) + + val tupleIso = MultiFocus.tuple[(Int, Int, Int), Int].andThen(shift) + val tupleRead = + (tupleIso.at(0)((10, 20, 30)) === 1010) + .and(tupleIso.at(1)((10, 20, 30)) === 1020) + .and(tupleIso.at(2)((10, 20, 30)) === 1030) + val tupleModify = tupleIso.modify(_ + 1)((10, 20, 30)) === ((11, 21, 31)) + val tupleReplaced = tupleIso.replace(7)((10, 20, 30)) + val tupleReplace = + (tupleIso.at(0)(tupleReplaced) === 7).and(tupleIso.at(2)(tupleReplaced) === 7) + + val repIso = MultiFocus.representable[Function1[Int, *], Int].andThen(shift) + val repSource: Int => Int = i => i * 10 + val repRead = (repIso.at(0)(repSource) === 1000).and(repIso.at(2)(repSource) === 1020) + val repWritten = repIso.modify(_ + 1)(repSource) + val repWrite = (repWritten(0) === 1).and(repWritten(1) === 11).and(repWritten(2) === 21) + + val appliedIso = MultiFocus + .apply[Function1[Int, *], Box[Int]] + .andThen(Iso[Box[Int], Box[Int], Int, Int](_.a, Box(_))) + val appliedSource: Int => Box[Int] = i => Box(i * 10) + val appliedWritten = appliedIso.modify(_ + 1)(appliedSource) + val appliedWrite = (appliedWritten(0) === Box(1)).and(appliedWritten(2) === Box(21)) + + tupleRead.and(tupleModify).and(tupleReplace).and(repRead).and(repWrite).and(appliedWrite) + } + + // covers: the mfAssocFunction1 BUNDLE branch read rule — the inner's bundle is read at the index + // the outer's element came from (the diagonal), not re-read from index 0. The write stays + // bundle-level for a bundle inner: its `from` consumes the whole rebuild once. + "grate ∘ grate reads the diagonal: element i of the outer's bundle at index i" >> { + val composed = MultiFocus + .apply[Function1[Int, *], Int => Int] + .andThen(MultiFocus.apply[Function1[Int, *], Int]) + val source: Int => (Int => Int) = i => j => i * 100 + j + + (composed.at(0)(source) === 0) + .and(composed.at(1)(source) === 101) + .and(composed.at(2)(source) === 202) + } + + // covers: MultiFocus.tuple's own write surface (modify per slot, replace broadcast). NOT a + // `.andThen` case: same-carrier F1 composition is pinned by the two blocks above. + "MultiFocus.tuple: modify per slot / replace broadcast" >> { val outer: Optic[(Int, Int), (Int, Int), Int, Int, MultiFocus[Function1[Int, *]]] = MultiFocus.tuple[(Int, Int), Int] val doubled = outer.modify(_ * 2)((10, 20)) (doubled === ((20, 40))).and(outer.replace(0)((1, 2)) === ((0, 0))) } + + // covers: the RepresentativeIndex witness — `Function1BroadcastOptic.from` reads a bundle at the + // index it was built with, which is the one place a Grate read needs a value it cannot compute. + // Shipped wrappers (`modify` / `collect*` / the kernel) hand it a CONSTANT bundle, so the witness + // is invisible through them; a varying bundle is read at the witness — a defined answer where the + // sentinel had a forged one. + "RepresentativeIndex: the bridged `from` reads at the supplied index; constant bundles are witness-invariant" >> { + // The varying-bundle read is white-box on purpose: outside this package the carrier's leftover + // is abstract, so no caller can even NAME a bundle whose value depends on the index (the only + // in-scope source of such a value is the optic's own `to`, which is constant). Inside, the + // product class is nameable and its `type X = Unit` comes with it. + val varying: MultiFocus[Function1[Int, *]][Unit, Int] = + MultiFocus[[a] =>> Int => a, Unit, Int]((), (i: Int) => i * 10) + val readAtZero = new Function1BroadcastOptic[Int, Int, Int, Int, Int](WitnessFixtures.isoInt, 0) + val readAtOne = new Function1BroadcastOptic[Int, Int, Int, Int, Int](WitnessFixtures.isoInt, 1) + val varies = (readAtZero.from(varying) === -1000).and(readAtOne.from(varying) === -990) + + // The public construction path: a local witness + the ordinary Composer. + def bridgeAt(i: Int): Optic[Int, Int, Int, Int, MultiFocus[Function1[Int, *]]] = + given RepresentativeIndex[Int] = RepresentativeIndex.at(i) + summon[Composer[Direct, MultiFocus[Function1[Int, *]]]].to(WitnessFixtures.isoInt) + + val atZero = bridgeAt(0) + val atOne = bridgeAt(1) + + // The bundle here is the optic's OWN (`to` ignores the index, it broadcasts the Iso's focus), so + // both witnesses agree — this is the shipped read path: `modify` / `replace` / `collect*` and the + // kernel's fallback. The Iso's read/write cancel, so the round trip is the identity either way. + val invariant = + (atZero.from(atZero.to(3)) === 3).and(atOne.from(atOne.to(3)) === 3) + val modifyInvariant = (atZero.modify(_ + 1)(3) === 4).and(atOne.modify(_ + 1)(3) === 4) + + varies.and(invariant).and(modifyInvariant) + } + + // covers: the witness does NOT become the write index — `grate ∘ iso` still rebuilds every + // position from its own focus, even when the witness in scope is a non-canonical one. This is the + // guarantee that separates the witness (a read-side convenience) from `broadcastFrom` (the + // kernel's per-index write). + "grate ∘ iso stays positional under a non-canonical witness" >> { + given RepresentativeIndex[Int] = RepresentativeIndex.at(2) + + val composed = MultiFocus.tuple[(Int, Int, Int), Int].andThen(WitnessFixtures.isoInt) + val modified = composed.modify(_ + 1)((10, 20, 30)) === ((11, 21, 31)) + val read0 = composed.at(0)((10, 20, 30)) === 1010 + val read2 = composed.at(2)((10, 20, 30)) === 1030 + + modified.and(read0).and(read2) + } + + // covers: the explicit construction path on an index type with no canonical inhabitant — the + // caller's witness (WitnessFixtures' `given RepresentativeIndex[Side]`) is what makes the inbound + // Iso bridge resolve, and the composite is positional over that algebraic index space. + "explicit witness on an algebraic index: bridge resolves and composes positionally" >> { + import WitnessFixtures.* + + val composed: Optic[Side => Int, Side => Int, Int, Int, MultiFocus[Function1[Side, *]]] = + grateSide.andThen(isoInt) + + val sides: Side => Int = s => if s == LeftSide then 1 else 2 + val modified = composed.modify(_ + 1)(sides) + // The Iso's read (`+1000`) and its write (`-1000`) cancel position by position — each side is + // rebuilt from its own focus, not from a single sampled one. + val positional = (modified(LeftSide) === 2).and(modified(RightSide) === 3) + val reads = (composed.at(LeftSide)(sides) === 1001).and(composed.at(RightSide)(sides) === 1002) + + positional.and(reads) + } + + // covers: the witness on a `Representable`-indexed grate whose index ORDER is a permutation of its + // field order (`Tri` / `Slot` above — the fixture #123 introduced for "position is a read-time + // argument"). `representable` pins the carrier's index to `F.Representation`, which has no + // canonical inhabitant, so the bridge takes the caller's witness — here `Slot.S1`, the SECOND + // index, not the first field — and the write still follows the instance's own `map`. + "witnessed permuted Representable: bridge takes the caller's index, write stays pointwise" >> { + val composed = + MultiFocus.representable[Tri, Int](using triRepresentable).andThen(WitnessFixtures.isoInt) + val tri = Tri(1, 2, 3) + + val points = composed.modify(_ + 1)(tri) === triFunctor.map(tri)(_ + 1) + // `replace` writes the composite's focus through the inner Iso's own `-1000`, pointwise — so the + // bare grate's `tabulate(_ => 0)` is this test's `tabulate(_ => -1000)`. + val replaces = composed.replace(0)(tri) === triRepresentable.tabulate(_ => -1000) + // The composite's `.at` reads the inner Iso's `+1000` on top of the instance's index order: + // S0 is the THIRD field (3) and S2 the second (2). + val reads = (composed.at(Slot.S0)(tri) === 1003).and(composed.at(Slot.S2)(tri) === 1002) + + points.and(replaces).and(reads) + } + + // covers: the cost side of the witness — an index type with no instance does not bridge at all, + // while the same shape with a witness does. Compile-pinned, no expected-type ascription. This is + // the trade the sentinel used to paper over: a read that has no index is refused rather than + // read at a value that cannot exist. + "RepresentativeIndex: witnessed index bridges, unwitnessed index does not resolve" >> { + import WitnessFixtures.* + + val witnessed = typeChecks("grateSide.andThen(isoInt)") + val unwitnessed = typeChecks("gratePhantom.andThen(isoPhantomFocus)") + + witnessed must beTrue + unwitnessed must beFalse + } + + // covers: the shipped instances themselves — every index type the factories fix with a canonical + // value has one, and it is a real value (`0` / `false` / `()` / the singleton), not a sentinel. + "RepresentativeIndex: the shipped instances name a real index" >> { + val indices = ( + summon[RepresentativeIndex[Int]].index, + summon[RepresentativeIndex[Boolean]].index, + summon[RepresentativeIndex[Unit]].index, + summon[RepresentativeIndex[true]].index, + ) + + indices === ((0, false, (), true)) + } + + // covers: the second shipped index type reaching the bridge with no local `given` — the companion + // instance is what resolves it, so a Boolean-indexed grate composes import-free like `tuple`. + "RepresentativeIndex: a Boolean-indexed grate bridges off the companion instance" >> { + import WitnessFixtures.* + + typeChecks("isoBoolFocus.andThen(grateBool)") must beTrue + } diff --git a/docs/research/2026-09-30-grate-witness-index.md b/docs/research/2026-09-30-grate-witness-index.md new file mode 100644 index 00000000..303e0ffb --- /dev/null +++ b/docs/research/2026-09-30-grate-witness-index.md @@ -0,0 +1,215 @@ +# Grate index witness — supplying the index instead of forging it + +**Question.** `MultiFocus[Function1[X0, *]]` (the Grate carrier) has exactly one read that no rule of +the type system can serve: `Function1BroadcastOptic.from` must turn a written bundle `X0 => B` back +into a `T`, and its own carrier stores only `Unit`. Baseline `81a53d3d` +(`fix/grate-positional-composition`) left that read at a `null` sentinel plus a documented +constant-bundle contract. This note measures what it costs to make that index a *real* value, and +what the change can and cannot buy. + +**Verdict, up front.** The witness is worth shipping as *hygiene* — the read stops being a forged +value and the constant-bundle contract stops being the only thing standing between the library and +an unspecified read — but it must not be sold as a fix. Nothing a user can write today changes +behaviour: every shipped path hands that `from` a constant bundle. The honest price is a new public +typeclass in implicit position on a shipped `Composer`, plus the loss of that `Composer` for index +types that have no inhabitant. See §7 for the recommendation and §6 for the comparison table. + +## 1. The stand-in sites before this change + +`core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala`, baseline `81a53d3d`: + +```scala +private[eo] inline def unobserved[A]: A = null.asInstanceOf[A] // the only null.asInstanceOf site +``` + +| # | site | stands in for | producer missing because | +|---|------|---------------|--------------------------| +| 1 | `Function1BroadcastOptic.from` — `foci(fb)(unobserved[X0])` | an **index** into a bundle the optic did not build | the carrier is `(Unit, X0 => A)` and the written focus lives inside the function | +| 2 | `mfAssocFunction1.composeFrom` bundle branch — `inner.from((unobserved[Xi], kD))` | the **inner optic's leftover** | `Z = Xo` threads the outer's leftover; the inner's is produced by `composeTo` and discarded | +| 3 | `collectList` — `o.from((unobserved[o.X], List(b)))` | the **optic's own leftover** | the aggregation rebuilds a singleton `List(b)` and never sees the leftover it discards | + +Site 1 is the only *index*. Sites 2 and 3 are existential leftovers: per-position data whose only +producer is the read side. + +## 2. What the witness removes + +`RepresentativeIndex[X0]` (new, `core/src/main/scala/dev/constructive/eo/data/RepresentativeIndex.scala`) +carries one real `X0`. The bridge takes it at construction and the optic stores it: + +```scala +final private[eo] class Function1BroadcastOptic[S, T, A, B, X0](o: Optic[S, T, A, B, Direct], at: X0) + extends Optic[S, T, A, B, MultiFocus[Function1[X0, *]]]: + def from(fb: MultiFocus[Function1[X0, *]][X, B]): T = broadcastFrom(MultiFocusK.foci(fb)(at)) + +given forgetful2multifocusFunction1[X0](using ri: RepresentativeIndex[X0]) + : Composer[Direct, MultiFocus[Function1[X0, *]]] with + def to[S, T, A, B](o: Optic[S, T, A, B, Direct]) = + new Function1BroadcastOptic[S, T, A, B, X0](o, ri.index) +``` + +Site 1 is gone: no `null` reaches a bundle read, and the read now has a stated answer even for a +bundle that varies. `unobserved` keeps exactly the two leftover sites, and its docstring gets +stronger — the remaining uses are *never* indices, so the class-wide invariant is now "an optic's +own leftover on a write path that discards it" rather than "an unobserved value, one of which might +be an index". + +## 3. What the witness provably cannot remove + +- **Site 2 (inner leftover).** A witness is an `X0`. What `inner.from` needs is an `Xi` — its own + leftover, produced per position by the inner's `to`. No index value can stand for it, and no + *single* value can either: nothing constrains the inner's leftover to be constant across the + index space. Recovering it means threading `X0 => Xi` through the composition's `Z` (a function of + the index, built in `composeTo`, consumed in `composeFrom`), which changes the public `Z` of every + `MF[F1] ∘ MF[F1]` composite from `Xo` to a pair. That is "thread the existential", not "make the + index real", and it is out of scope here. This is the sharp version of the user-visible summary: + **a per-position write needs one value per index, not one index.** +- **Site 3 (own leftover).** `collectList` collapses a focus list to one element; the leftover that + would rebuild through it was never read. Same shape: an index witness cannot reach it. +- **An empty index space.** `Function1[Nothing, *]`-indexed (a phantom slot) has no inhabitant to + witness, so the variant *refuses* the bridge rather than reading a bundle at an impossible index. + That is the mechanical cost of the whole path: the sentinel existed because a read with no index + still had to return something. + +## 4. Index availability per shipped Grate factory + +The factories never need an index themselves — they walk their own space (`tuple` counts +`0..size-1`, `representable` tabulates). The index is needed by the *bridge*, and its type is fixed +by whichever carrier a Direct optic is being composed into: + +| carrier source | `X0` | usable index value? | +|---|---|---| +| `MultiFocus.tuple[T, A]` | `Int` | yes — `0`, in range whenever `T` is non-empty (the shipped `GrateShapeSpec` / `MultiFocusFunction1Spec` cells are all this shape) | +| `MultiFocus.representable[F, A]` | `F.Representation` | **no** — `cats.Representable` exposes `index` / `tabulate` / the abstract `Representation` type and nothing else (verified with `cellar get-external org.typelevel:cats-core_3:2.13.0 cats.Representable`), so the caller must supply one (`MultiFocusFunction1Spec` does it with one `given` line on a permuted three-point instance) | +| `MultiFocus.apply[F, A]` over `F = Function1[X0, *]` | `X0` | no — whatever the function's domain type is; `Int` and `Boolean` are covered by shipped instances, everything else is caller-supplied | +| the bridge itself (`Direct → MF[F1[X0, *]]`) | free `X0` | no — this is the constraint under discussion | + +### The adjacent ruling on main (#123), and how this differs + +`origin/main` `6e53b857` retired `MultiFocus.representableAt(F)(repr0)` on the grounds that **position +is a read-time argument, never a property of the optic** — the factory tabulated pointwise, so `repr0` +never reached the built optic, and `.at(i)` subsumes it per call. That is the same *class* of +question this note asks (should an index live on the optic?), and the maintainer answered it with +"no" — twice over: the ruling also records that giving `repr0` runtime meaning would take "a field +on the optic class plus a runtime witness match (the `Function1BroadcastOptic` shape)", i.e. exactly +this prototype's mechanism. Two things separate the cases, and they are the load-bearing part of the +recommendation: + +1. **`.at(i)` subsumes `repr0`; nothing subsumes the bridge's index.** A built Grate optic's reads + are all caller-driven, so a construction-time index has no reader. The bridge's `from` is the + opposite: the kernel hands it a whole bundle and there is no caller to ask, so its options are a + real value at construction or a forged one at the read. +2. **A construction-time index is a claim about the optic; a bridge witness is not.** `repr0` said + "this optic is *at* this index" — false, since two calls built the same optic. `RepresentativeIndex` + says "if you ever have to read a bundle this optic did not build, read it here" — a fallback + position, unobservable on every shipped path (§8). + +The honest residual: the shipped `Boolean` → `false` instance *does* privilege a value, in the very +way #123's ruling declines to for `Function1[Boolean, *]`. It is defensible only as a convenience for +implicitness, and the alternative (no shipped instances) is priced in §7. + +## 5. The candidate supplies, measured + +| route | verdict | +|---|---| +| **`Representable`-derived** | **Impossible.** No `Representation` value exists in the typeclass; `representableAt` (retired on main, see §4) had to take one from the caller for the same reason. | +| **`ValueOf` / singleton-typed** | **Does not cover the shipped surface.** `ValueOf[Int]`, `[Boolean]`, `[Unit]`, `[Long]` do not resolve (measured: compile error on `summon[Option[ValueOf[Int]]]` in a scratch spec); synthesis exists only for literal / stable singleton types, and only in a `using` position. Gating the bridge on `ValueOf` would turn the `iso ∘ grate` cell red for every shipped Grate (`tuple`'s index is `Int`). Kept as a *secondary* instance (`given singleton[X0 <: Singleton](using ValueOf[X0])`) for the exotic case — `def narrow[R <: Singleton](r: R)` does infer `R = true` for `narrow(true)` (measured), so literal index types work. | +| **Witness typeclass with canonical instances + explicit `at`** | **This prototype.** `RepresentativeIndex[Int] = 0`, `[Boolean] = false`, `[Unit] = ()`, singletons via `ValueOf`, everything else via `RepresentativeIndex.at(i)` (local `given` or explicit). Keeps all 6 composing Grate cells green (the shipped index types all have instances) and keeps `.andThen` import-free. | +| **Explicit-index bridge only** (no typeclass, no implicit `Composer`) | Not prototyped: it deletes the only inbound bridge for the absorbed-Grate sub-shape, so `iso ∘ grate` (and `Iso → Traversal.two/three/four`, which ride the same morph) would have to be written as `.morph(using …)` everywhere. The cell count in `GrateShapeSpec` would drop by one and `CompositionMatrixSpec`/docs would follow. This is the pure form of the trade; the prototype's typeclass is strictly weaker (it keeps implicitness wherever an index exists) at the cost of a public typeclass. | + +## 6. Comparison table + +| axis | sentinel (baseline `81a53d3d`) | witness (`RepresentativeIndex`) | +|---|---|---| +| **implicitness** | `iso.andThen(grate)` resolves with no imports, for every `X0` | still resolves with no imports for `Int` / `Boolean` / `Unit` / singleton indices (companion instances — nothing to import); **refused** for an index type with no instance unless one is put in scope | +| **API churn** | zero | new public `data.RepresentativeIndex[X0]` (+ `at`, 4 instances); `forgetful2multifocusFunction1` gains a `using`; `Function1BroadcastOptic` gains a field (both `private[eo]`); docs: `multifocus.md` bridge row + the "carries no constraint" sentence, QA grate legend, CHANGELOG (MiMa is off on 0.x) | +| **index availability** | irrelevant — nothing is read | `tuple` ✓ (`Int`→0), `representable` ✗ needs a caller witness per index type (`F.Representation` has no canonical inhabitant — #123's ruling, §4), `apply[F1[X0,*]]` ✗ for non-`Int`/`Boolean` `X0`, bridge over an arbitrary `X0` ✗ | +| **ergonomics** | nothing to write | a local `given RepresentativeIndex[X] = RepresentativeIndex.at(v)` (one line) where no canonical instance exists; on a `Representable`-indexed grate that is the same value the read side passes to `.at(i)` per call | +| **behaviour for an uninhabited `X0`** | compiles; the read can only be reached through the constant-bundle contract (a lambda that ignores its argument never dereferences the sentinel) | **does not compile** — no instance can be produced for a type with no inhabitant, so the read that has no answer is refused at the bridge | +| **observable behaviour, shipped paths** | identical | identical (measured: witness 0 vs witness 1 give the same `modify` / round-trip results) | + +## 7. Recommendation, with the honest cost + +**Ship the witness as hygiene, not as a fix** — the change is small, keeps every shipped composition +cell green, and retires the one place where core forged an *index*. Do not expect it to change any +result a user can currently observe; §8 is the evidence for why. Read §4's #123 subsection first: +the maintainers have already ruled against construction-time indices once, and this path has to be +the exception it is (nothing subsumes the bridge's index) rather than a quiet contradiction of it. + +What the developer pays: + +1. **A public typeclass in implicit position on a shipped `Composer`.** New API surface, a new + failure mode (`iso.andThen(grate)` stops resolving for a non-canonical index type, with an + implicit-not-found message naming `RepresentativeIndex` — mitigated by an `@implicitNotFound` + if that matters), and a subtle footgun: a *local* witness silently changes the position read on + a varying bundle. It cannot change a constant-bundle path. +2. **A capacity regression for empty index spaces.** The `iso ∘ grate` chain no longer compiles + when the grate's index type has no inhabitant. Nothing in the tree exercises it, and the read it + would have taken is unanswerable, but it *was* reachable before. +3. **A field that shipped paths do not read.** This is the same *shape* as the deleted + `MultiFocusLeadPosition` (dropped in `12b827b9` as dead code, +20% on `Grate.modify`), and #123 + declined the same shape for `repr0`. The difference is that this field is read where a value is + genuinely required, and §8 pins that read with a test. It is still not benchmarked: + `MultiFocusCollectBench` drives `MultiFocus.tuple` directly and never builds a bridge, so the + change cannot show up in the Grate bench (neither as a win nor as a regression). Reasoning from + the code, not from a run: the bridge instance is built once per morph, outside any measured loop. +4. **A shipped instance that privileges a value** (`Boolean` → `false`). Drop the `Boolean` / + `Int` / `Unit` instances and this cost disappears at the price of three red cells and a `given` + line at every call site that has no witness of its own — the sharper trade if the team reads + #123's ruling strictly. + +Alternatives, and why they lose here: + +- *Explicit-index bridge only* — maximal honesty, deletes a documented capability. Only worth it if + the team wants the Grate inbound to be a deliberate, spelled-out act. +- *Keep the sentinel* — the baseline is defensible: sites 2 and 3 stay either way, so the "no + `null` in core" argument is already half-compromised, and the constant-bundle contract is + enforced by the kernel's `Function1BroadcastOptic` dispatch rather than by a value. If the team + also wants to hold #123's line consistently (no index on an optic, ever), this is the coherent + answer rather than this prototype. +- *Remove the read* — if `from` could avoid reading a bundle (a carrier whose write does not travel + as a function), no index would be needed at all. That is a carrier change, not a bridge change, + and it is the other design path's territory. + +## 8. Prototype evidence + +Branch `feat/grate-witness-index`, rebased onto `origin/main` `6e53b857` (the #123 retirement) on top +of `81a53d3d`. The rebase also dropped every stale `representableAt` mention the branch carried, added +the break entry to `mima.sbt` per the line's convention, and wired the new witness block onto #123's +own permuted `Tri` / `Slot` fixture. + +| test (`core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala`) | pins | +|---|---| +| `RepresentativeIndex: the bridged from reads at the supplied index; constant bundles are witness-invariant` | the read really is at `at` (`-1000` vs `-990` for a varying bundle read at index 0 vs 1 — white-box, see below); the shipped path is invariant (`from(to(x)) == x`, `modify(_+1)(3) == 4` under both witnesses) | +| `grate ∘ iso stays positional under a non-canonical witness` | the baseline's positional guarantee survives a deliberately "wrong" witness (`at(2)`): `modify(_+1)((10,20,30)) == (11,21,31)`, reads at 0/2 unchanged — the witness is not a write index | +| `explicit witness on an algebraic index` | a `sealed trait Side` index (no canonical inhabitant, one `given` line) bridges, composes and stays positional | +| `witnessed permuted Representable` | on #123's own `Tri` / `Slot` fixture (index order is a permutation of field order), the witness `S1` is what resolves the bridge over a `representable` grate, and the write still equals the instance's own `map` — position stays a property of the *call*, not of the optic | +| `RepresentativeIndex: witnessed index bridges, unwitnessed index does not resolve` | `typeChecks` cells: same carrier shape with a witness → true, without → false | +| `RepresentativeIndex: the shipped instances name a real index` | `0` / `false` / `()` / the singleton — so the instances are values, not a renamed sentinel | +| `RepresentativeIndex: a Boolean-indexed grate bridges off the companion instance` | the *second* shipped index type reaches the bridge with no local `given` (the doc claim that the grid stays import-free) | + +All seven, plus the full root aggregate (`sbt test`: all modules, 0 failures), `GrateShapeSpec` / +`CompositionMatrixSpec`, and the line's CI gate set — `scalafmtCheckAll`, `scalafmtSbtCheck`, +`benchmarks/scalafmtCheck`, `scalafixAll --check`, `githubWorkflowCheck`, `mimaReportBinaryIssues` — +plus `docs/mdoc` and `docs/laikaSite`, pass. Two measurements worth keeping: + +- **The witness's observable surface is empty on the public API.** The carrier's `X` is abstract at + every call site that goes through `Composer.to`, so a *data-dependent* bundle cannot even be + named by a caller: `val b: MultiFocus[F1[Int, *]][bridged.X, Int] = MultiFocus(...)((), i => i * 10)` + fails with `Found: Unit, Required: bridged.X`, and the only bundle anyone can build for that + receiver is the optic's own constant `to`. The varying-bundle test above is therefore white-box + (`Function1BroadcastOptic` is `private[eo]`, and the spec lives in that package). This is the + strongest argument *for* the witness (the read is unobservable, so its correctness should be + structural, not a contract) and against overselling it. +- **A price-free variant does not exist.** Removing the *category* rather than the instance would + need an index-free carrier for the bridge's write; every read of an `X0 => B` needs an `X0`. + +## 9. What would change this answer + +- If a user-visible API ever hands a **varying** bundle to a broadcast `from` (a `put`/`place`-style + entry point, an existential-bundle composition), the witness stops being hygiene and becomes the + only thing keeping that read defined — ship it now, cheaper than later. +- If the Grate inbound is meant to be spelled out at every call site (a deliberate engineer-facing + gate), drop the implicit `Composer` and keep only `RepresentativeIndex.at` — a one-line change on + top of this prototype. +- If the team prefers zero new public surface, keep the sentinel and re-word the contract; sites 2 + and 3 mean the "no forged value" story is already partial. diff --git a/mima.sbt b/mima.sbt index e03eebc3..a2b0e6f3 100644 --- a/mima.sbt +++ b/mima.sbt @@ -6,6 +6,15 @@ // cats-eo-avro has no published baseline anyway. Breaking-change // history, newest first: // +// 0.19: the `Iso → MultiFocus[Function1[X0, *]]` bridge +// (`forgetful2multifocusFunction1`) now requires +// `data.RepresentativeIndex[X0]` — the index its product's own `from` +// reads a bundle at, where a `null` sentinel used to stand in. `Int` / +// `Boolean` / `Unit` / singleton indices resolve off the companion +// (unchanged call sites), any other index type needs a local `given` +// or `RepresentativeIndex.at(v)`, and an uninhabited index type no +// longer bridges (source-breaking for direct references to the given, +// and for chains over an index type with no instance). // 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), diff --git a/site/docs/multifocus.md b/site/docs/multifocus.md index 23e6275b..a0a80fa4 100644 --- a/site/docs/multifocus.md +++ b/site/docs/multifocus.md @@ -246,7 +246,7 @@ rejected** rather than absent — see | 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`. | +| `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](quality-assurance.md#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`. | @@ -271,11 +271,35 @@ specialised by `F`: `cats.data.Chain`. Singleton fast-path via `MultiFocusSingleton` (so morphed Lenses skip the per-element `F.pure` round-trip). - **`mfAssocFunction1`** — the absorbed-Grate sub-shape's body for - `F = Function1[X0, *]`. `Z = (Xo, Xi)`; the rebuild is a + `F = Function1[X0, *]`. `Z = Xo` — the kernel threads the *outer's* + leftover from `composeTo` into `composeFrom`, so the outer's own + `from` receives what its own `to` produced — and the rebuild is a closure-on-closure, no per-element accumulator. Lights up for `MultiFocus.representable`, `MultiFocus.tuple`, and - `Traversal.{two,three,four}`. -- **`mfAssocPSVec`** — the absorbed-PowerSeries body for `F = PSVec`. + `MultiFocus.apply`. The inner decides how a + bundle is written, and there are exactly two cases: + - a **broadcast inner** — the `Iso → MultiFocus[Function1]` bridge + and anything composed from it, witnessed by the + `Function1BroadcastOptic` it produces (`private[eo]`) — holds exactly + one focus, so its write is composed *per index*: position `i` is + built from the written focus `i` (`broadcastFrom`). That is what + makes `grate ∘ iso` rewrite every position instead of collapsing + onto position 0. Its *read* needs no special case: `to` broadcasts + the focus, so the kernel reads the bundle at whatever index it + wants. That optic's own `from` is the one place the library has to + read a bundle it did not build, so the bridge is handed a + `RepresentativeIndex[X0]` at construction and reads there — a real + index, never a sentinel (see + [the Grate sub-shape](quality-assurance.md#the-grate-sub-shape)). + - a **bundle inner** — everything else, e.g. `MultiFocus.apply` as + the inner — has a bundle-level `from`, so it consumes the whole + write bundle once and the outer receives the constant rebuild of + that single result. + + Reads are per index in both cases: the inner's bundle is read at the + same index its element came from. Rebuilds that ignore their argument + entirely (the fixed-arity `Traversal.{two,three,four}` era) behave + identically under either rule.- **`mfAssocPSVec`** — the absorbed-PowerSeries body for `F = PSVec`. Parallel-array `AssocSndZ` leftover (saves the per-element Tuple2 the generic body would build). AlwaysHit fast-path via `MultiFocusSingleton`, MaybeHit fast-path via @@ -353,11 +377,34 @@ 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[...])` work, but -`lens.andThen(grate)` does not. (`Traversal.two/three/four` are -unaffected: they ride `MultiFocus[PSVec]` and compose freely.) +`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](quality-assurance.md#the-grate-sub-shape) +(`GrateShapeSpec`) with the composed behaviour pinned by +`MultiFocusFunction1Spec`. ## Worked examples @@ -458,6 +505,9 @@ def representable[F: Representable, A] // 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 diff --git a/site/docs/optics.md b/site/docs/optics.md index 6054e655..24df5b7c 100644 --- a/site/docs/optics.md +++ b/site/docs/optics.md @@ -136,6 +136,21 @@ with a read-only side), and the `ReverseAccessor`-gated build-collapse [Concepts → Composition lattice](concepts.md#composition-lattice) for the carrier-level bridge graph. +The grid is **carrier-level**, and two of its families are sub-shape +families: `Traversal` is `MultiFocus[PSVec]` (`each`, `Plated`), while +the Grate rides `MultiFocus[Function1[X0, *]]` (`MultiFocus.tuple` / +`representable` / `apply`) and composes with far fewer families — it is +void against every single-focus family in both directions, because its +write-back cannot pick one focus out of a Naperian bundle and its read +side cannot enumerate a function's codomain. The pinned footprint is +[QA → The Grate sub-shape](quality-assurance.md#the-grate-sub-shape); +the rationale is under +[MultiFocus → Composition limits](multifocus.md#composition-limits). +Also note what a ✓ asserts: that the chain *type-checks* — no import, +no ascription — not that the composite is behaved. Behaviour is pinned +by the specs (`MultiFocusFunction1Spec` for the Grate carrier's +composition rules). + ```scala mdoc:silent import dev.constructive.eo.optics.{Lens, Optic} import dev.constructive.eo.optics.Optic.* diff --git a/site/docs/quality-assurance.md b/site/docs/quality-assurance.md index 5fd32b4b..1672b53d 100644 --- a/site/docs/quality-assurance.md +++ b/site/docs/quality-assurance.md @@ -8,16 +8,19 @@ reflect that, in roughly increasing cost-to-fool order: compile time, exactly which optic families compose with which (and at what strength), and which combinations are deliberately rejected. A regression that loosened or broke the lattice fails to compile. -2. **Discipline law suites** — `cats-eo-laws` defines the optic and typeclass +2. **Sub-shape grids** — the matrix is carrier-level, so the two + `MultiFocus` sub-shapes that behave differently get their own pinned table: + [the Grate footprint](#the-grate-sub-shape) below. +3. **Discipline law suites** — `cats-eo-laws` defines the optic and typeclass laws; `cats-eo-tests` and the integration modules run them against concrete instances. -3. **Statement / branch [coverage](#coverage)** (scoverage) — the project's +4. **Statement / branch [coverage](#coverage)** (scoverage) — the project's primary runtime-quality signal. See the [`CLAUDE.md` coverage note](https://github.com/Constructive-Programming/eo/blob/main/CLAUDE.md) for why ~70–80 % is the expected ceiling: the remainder is pure type-level machinery with no runtime footprint, or code reachable only once a downstream carrier instance is added. -4. **[Mutation testing](#mutation-testing)** (stryker4s) — the strongest and +5. **[Mutation testing](#mutation-testing)** (stryker4s) — the strongest and most expensive signal, and the one that historically did *not* pay its way here. It was reintroduced once the `schemes` module grew real runtime machinery (the `ArrayDeque` fold machine, the effectful M-drivers, `PSVec`) @@ -57,8 +60,56 @@ ascription, that spec goes red. *✓ composes import-free at the strength shown in the [optic taxonomy](optics.md); ✗ does not compile (void by design — building through a read-only optic, reading through a write-only one, etc.). 87 composing / 34 void cells, pinned by `CompositionMatrixSpec`.* +*✓ is a **typing claim**: the chain resolves with no expected-type ascription and no `given` imports, landing at the family shown. It does not say the composite is *behaved* — runtime semantics are pinned by behaviour specs (`MultiFocusFunction1Spec` for the Grate carrier's composition rules). The grid is also carrier-level: `trav` and `fold` describe `MultiFocus[PSVec]` (the `Traversal` class, `each`, `Plated`); the other shipped MultiFocus sub-shape has its own table below.* + +### The Grate sub-shape + +`trav` and `fold` in the grid above mean `MultiFocus[PSVec]` — the `Traversal` +class, `each`, `Plated`. The other shipped `MultiFocus` sub-shape is the +**Grate** (`MultiFocus[Function1[X0, *]]`, built by `MultiFocus.tuple` / +`representable` / `apply`), and its footprint is much +narrower: a single-focus outer's write-back would have to *pick* one focus out +of a Naperian bundle, and a read-collapse would have to *enumerate* a function's +codomain. Neither is available, so the single-focus families are void against it +in both directions. + +

+ MultiFocus reference → Grate · + Composition limits +

+ + + +| family `f` | `f` ∘ grate | grate ∘ `f` | +|---|---|---| +| **iso** | ✓ | ✓ | +| **lens** | ✗ | ✗ | +| **prism** | ✗ | ✗ | +| **optional** | ✗ | ✗ | +| **trav** | ✗ | ✗ | +| **getter** | ✗ | ✗ | +| **affold** | ✗ | ✗ | +| **fold** | ✗ | ✗ | +| **modify** | ✓ | ✓ | +| **review** | ✗ | ✗ | +| **unfold** | ✗ | ✗ | +| **grate** | ✓ | ✓ | + +*Same ✓ / ✗ meaning as the grid above, restricted to the Grate sub-shape (`MultiFocus[Function1[X0, *]]`, the Naperian factories `MultiFocus.tuple` / `representable` / `apply`): 6 composing / 18 void cells, pinned by `GrateShapeSpec`. +The inbound `iso` cell needs a `RepresentativeIndex` for the grate's index type — shipped off the companion for every index type the factories fix with a canonical value (`Int` / `Boolean` / `Unit` / singletons), so the grid stays import-free; a grate over an algebraic index needs `given RepresentativeIndex[X] = RepresentativeIndex.at(v)` in scope, and an uninhabited index type gets no instance — the bridge refuses rather than reading a bundle at an index that cannot exist. The ✗ cells are structural, not missing plumbing: a Lens / Traversal write-back would have to pick one focus out of a Naperian bundle (`Foldable[Function1[X0, *]]`: no instance, and no lawful one — a function's codomain is not enumerable); a Prism / Optional miss would need `Alternative[Function1[X0, *]]` (`empty` has no value to return); a Getter / AffineFold / Fold read-collapse would have to enumerate that codomain. `trav` / `fold` in this table mean the *other* MultiFocus sub-shape across the seam — cross-`F` composition needs a per-`F` natural transformation and is a documented workaround.* + + + +The two tables answer different questions: the grid says which *families* +compose, this table says which of them reach the Naperian sub-shape. Cell +verdicts come from [`GrateShapeSpec`](https://github.com/Constructive-Programming/eo/blob/main/tests/src/test/scala/dev/constructive/eo/GrateShapeSpec.scala) +(compile-level, same no-import / no-ascription doctrine) and the *behaviour* of +the cells that do compose is pinned by +[`MultiFocusFunction1Spec`](https://github.com/Constructive-Programming/eo/blob/main/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala) +— because a ✓ here only promises that the chain type-checks. + ## Coverage Statement and branch coverage per package, from the cross-module scoverage diff --git a/site/tools/gen-qa-report.py b/site/tools/gen-qa-report.py index f5444a86..bbaa3452 100644 --- a/site/tools/gen-qa-report.py +++ b/site/tools/gen-qa-report.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Regenerate the data tables on site/docs/quality-assurance.md. -Three tables, each written between a `` / -`` marker pair so the hand-authored prose around +Three tables, each written between a `` / `` marker pair so the hand-authored prose around + them is preserved: - matrix : the composition matrix as a pass (✓) / fail (✗) map, parsed @@ -10,6 +10,11 @@ the single source of truth — every `typeChecks(...) must beTrue` is a cell that composes, every `must beFalse` a void-by-design cell). + - grate : the same pass/fail projection restricted to the Grate sub-shape + (`MultiFocus[Function1[X0, *]]`), parsed from + tests/.../GrateShapeSpec.scala. The main grid is carrier-level: + its `trav` / `fold` rows describe `MultiFocus[PSVec]`, so the + Naperian sub-shape needs its own table rather than a footnote. - coverage : per-package statement % and branch % with their BC/SC ratio, computed from the scoverage AGGREGATE report by counting elements directly (matches scoverage's own @@ -40,6 +45,12 @@ "CompositionMatrixSpec.scala", ) +# The Grate (Naperian Function1) sub-shape's own grid. +GRATE_SPEC = os.path.join( + ROOT, "tests", "src", "test", "scala", "dev", "constructive", "eo", + "GrateShapeSpec.scala", +) + # Outer/inner family order — matches the spec's row order and the optics.md # taxonomy. `trav` = Traversal, `affold` = AffineFold. FAMILIES = [ @@ -89,10 +100,13 @@ def splice(page: str, marker: str, body: str) -> str: # -------------------------------------------------------------------------- # matrix # -------------------------------------------------------------------------- -def gen_matrix() -> str: - src = open(SPEC, encoding="utf-8").read() - # Cell titles read `" ∘ ..." >>`; the verdict is the first - # `typeChecks("...andThen...") must beTrue|beFalse` inside the cell block. +def parse_cells(spec: str) -> dict: + """Parse one spec file into `{(outer, inner): composes}`. + + Cell titles read `" ∘ ..." >>`; the verdict is the first + `typeChecks("...andThen...") must beTrue|beFalse` inside the cell block. + """ + src = open(spec, encoding="utf-8").read() cell_re = re.compile(r'"(\w+)\s*∘\s*(\w+)[^"]*"\s*>>') verdict_re = re.compile(r"must\s+(beTrue|beFalse)") cells = {} @@ -104,6 +118,18 @@ def gen_matrix() -> str: v = verdict_re.search(block) if v: cells[(outer, inner)] = v.group(1) == "beTrue" + return cells + + +def cell(ok) -> str: + """Render one parsed verdict; `None` (no cell in the spec) is `·`.""" + if ok is None: + return "·" + return "✓" if ok else "✗" + + +def gen_matrix() -> str: + cells = parse_cells(SPEC) head = "| outer ∘ inner | " + " | ".join(FAMILIES) + " |" sep = "|" + "---|" * (len(FAMILIES) + 1) @@ -127,7 +153,68 @@ def gen_matrix() -> str: f"[optic taxonomy](optics.md); ✗ does not compile (void by design — " f"building through a read-only optic, reading through a write-only one, " f"etc.). {n_pass} composing / {n_fail} void cells, pinned by " - f"`CompositionMatrixSpec`.*" + f"`CompositionMatrixSpec`.*\n" + f"\n*✓ is a **typing claim**: the chain resolves with no expected-type " + f"ascription and no `given` imports, landing at the family shown. It does " + f"not say the composite is *behaved* — runtime semantics are pinned by " + f"behaviour specs (`MultiFocusFunction1Spec` for the Grate carrier's " + f"composition rules). The grid is also carrier-level: `trav` and `fold` " + f"describe `MultiFocus[PSVec]` (the `Traversal` class, `each`, `Plated`); " + f"the other shipped MultiFocus sub-shape has its own table below.*" + ) + return "\n".join(rows) + "\n" + legend + + +# -------------------------------------------------------------------------- +# grate +# -------------------------------------------------------------------------- +def gen_grate() -> str: + """The Grate sub-shape's footprint, in the two directions that matter. + + Rows are the same families as the main grid plus `grate`; the two columns are + "family as the outer, grate as the inner" and its mirror. `grate ∘ grate` is + the same cell in both columns. + """ + cells = parse_cells(GRATE_SPEC) + rows = [ + "| family `f` | `f` ∘ grate | grate ∘ `f` |", + "|---|---|---|", + ] + n_pass = n_fail = n_missing = 0 + for f in [*FAMILIES, "grate"]: + inner_ok = cells.get((f, "grate")) + outer_ok = cells.get(("grate", f)) + for ok in (inner_ok, outer_ok): + if ok is None: + n_missing += 1 + elif ok: + n_pass += 1 + else: + n_fail += 1 + rows.append(f"| **{f}** | {cell(inner_ok)} | {cell(outer_ok)} |") + legend = ( + f"\n*Same ✓ / ✗ meaning as the grid above, restricted to the Grate " + f"sub-shape (`MultiFocus[Function1[X0, *]]`, the Naperian factories " + f"`MultiFocus.tuple` / `representable` / `apply`): " + f"{n_pass} composing / {n_fail} void cells, pinned by `GrateShapeSpec`" + + (f" — {n_missing} cell(s) missing.\n" if n_missing else ".\n") + + f"The inbound `iso` cell needs a `RepresentativeIndex` for the grate's " + f"index type — shipped off the companion for every index type the " + f"factories fix with a canonical value (`Int` / `Boolean` / `Unit` / " + f"singletons), so the grid stays import-free; a grate over an " + f"algebraic index needs `given RepresentativeIndex[X] = " + f"RepresentativeIndex.at(v)` in scope, and an uninhabited index type " + f"gets no instance — the bridge refuses rather than reading a bundle at " + f"an index that cannot exist. " + + f"The ✗ cells are structural, not missing plumbing: a Lens / Traversal " + f"write-back would have to pick one focus out of a Naperian bundle " + f"(`Foldable[Function1[X0, *]]`: no instance, and no lawful one — a " + f"function's codomain is not enumerable); a Prism / Optional miss would " + f"need `Alternative[Function1[X0, *]]` (`empty` has no value to return); a " + f"Getter / AffineFold / Fold read-collapse would have to enumerate that " + f"codomain. `trav` / `fold` in this table mean the *other* MultiFocus " + f"sub-shape across the seam — cross-`F` composition needs a per-`F` " + f"natural transformation and is a documented workaround.*" ) return "\n".join(rows) + "\n" + legend @@ -240,6 +327,7 @@ def main() -> int: page = open(PAGE, encoding="utf-8").read() out = page out = splice(out, "matrix", gen_matrix()) + out = splice(out, "grate", gen_grate()) out = splice(out, "coverage", gen_coverage()) out = splice(out, "mutation", gen_mutation()) if out == page: diff --git a/tests/src/test/scala/dev/constructive/eo/GrateShapeSpec.scala b/tests/src/test/scala/dev/constructive/eo/GrateShapeSpec.scala new file mode 100644 index 00000000..3f3cfbc6 --- /dev/null +++ b/tests/src/test/scala/dev/constructive/eo/GrateShapeSpec.scala @@ -0,0 +1,166 @@ +package dev.constructive.eo + +// ===================================================================== +// The Grate sub-shape grid — the companion to CompositionMatrixSpec. +// +// CompositionMatrixSpec's `trav` / `fold` rows describe +// `MultiFocus[PSVec]` (the `Traversal` class, `each`, `Plated`). The +// other shipped MultiFocus sub-shape — the Grate, i.e. +// `MultiFocus[Function1[X0, *]]` over the Naperian factories +// (`MultiFocus.tuple` / `representable` / `apply`) +// — has a materially NARROWER composition footprint. This spec pins it, +// so the QA page can show it and a future bridge cannot silently move a +// cell. +// +// Why the ✗ cells are structural, not missing plumbing: +// - a Lens / Traversal write-back would have to pick one focus out of a +// Naperian bundle => needs `Foldable[Function1[X0, *]]`: no instance +// (and no lawful one — a function's codomain is not enumerable) +// - a Prism / Optional miss would need `Alternative[Function1[X0, *]]` +// (`empty`, i.e. `X0 => A` with no `A`): impossible +// - a Getter / AffineFold / Fold read-collapse would have to enumerate +// the codomain: no lawful fold +// - cross-`F` MultiFocus composition (PSVec ∘ Function1) needs a per-`F` +// natural transformation: documented workaround only +// - the inbound `iso ∘ grate` cell resolves through +// `Composer[Direct, MultiFocus[Function1[X0, *]]]`, which asks for a +// `RepresentativeIndex[X0]` — the index its product's own `from` reads a +// bundle at. `Int` / `Boolean` / `Unit` / singleton index types resolve +// off that companion (still no imports), which is every index type these +// fixtures use; an algebraic index needs a local `given`, and an +// uninhabited one does not bridge at all. See +// `docs/research/2026-09-30-grate-witness-index.md`. +// Same doctrine as CompositionMatrixSpec: no expected-type ascription and +// no `given` imports — a cell that starts needing either goes red. +// ===================================================================== + +import scala.compiletime.testing.typeChecks + +import org.specs2.mutable.Specification + +import optics.* +import data.MultiFocus + +object GrateFixtures: + case class Box[A](a: A) + + // Column direction: outers whose focus IS the Grate's source (`Int => Int`). + val o_iso = Iso[Box[Int => Int], Box[Int => Int], Int => Int, Int => Int](_.a, Box(_)) + val o_lens = Lens[Box[Int => Int], Int => Int](_.a, (s, m) => Box(m)) + val o_prism = Prism[Box[Int => Int], Int => Int](b => Right(b.a), Box(_)) + + val o_optional = + Optional[Box[Int => Int], Box[Int => Int], Int => Int, Int => Int]( + b => Right(b.a), + sb => Box(sb._2) + ) + + val o_trav = Traversal.each[List, Int => Int] + val o_getter = Getter[Box[Int => Int], Int => Int](_.a) + val o_affold = AffineFold[Box[Int => Int], Int => Int](b => Some(b.a)) + val o_fold = Fold[List, Int => Int] + + val o_modify = + Modify[Box[Int => Int], Box[Int => Int], Int => Int, Int => Int](f => b => Box(f(b.a))) + + val o_review = Review[Box[Int => Int], Int => Int](Box(_)) + val o_unfold = Unfold((xs: List[Int => Int]) => Box(xs.head)) + val i_grate = MultiFocus.apply[Function1[Int, *], Int] + + // Row direction: the Grate as outer, inners sourced on its focus. + val g_box = MultiFocus.apply[Function1[Int, *], Box[Int]] + val g_list = MultiFocus.apply[Function1[Int, *], List[Int]] + val g_fun = MultiFocus.apply[Function1[Int, *], Int => Int] + + val i_iso = Iso[Box[Int], Box[Int], Int, Int](_.a, Box(_)) + val i_lens = Lens[Box[Int], Int](_.a, (s, m) => Box(m)) + val i_prism = Prism[Box[Int], Int](b => Right(b.a), Box(_)) + val i_optional = Optional[Box[Int], Box[Int], Int, Int](b => Right(b.a), sb => Box(sb._2)) + val i_getter = Getter[Box[Int], Int](_.a) + val i_affold = AffineFold[Box[Int], Int](b => Some(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 i_unfold = Unfold((xs: List[Box[Int]]) => Box(xs.head)) + val i_each = Traversal.each[List, Int] + val i_fold = Fold[List, Int] + +class GrateShapeSpec extends Specification: + import GrateFixtures.* + + "Grate sub-shape — family ∘ grate (the Grate as inner)" >> { + "iso ∘ grate → MultiFocus[Function1[Int, *]]" >> { + typeChecks("o_iso.andThen(i_grate)") must beTrue + } + "lens ∘ grate must not compile" >> { + typeChecks("o_lens.andThen(i_grate)") must beFalse + } + "prism ∘ grate must not compile" >> { + typeChecks("o_prism.andThen(i_grate)") must beFalse + } + "optional ∘ grate must not compile" >> { + typeChecks("o_optional.andThen(i_grate)") must beFalse + } + "trav ∘ grate must not compile" >> { + typeChecks("o_trav.andThen(i_grate)") must beFalse + } + "getter ∘ grate must not compile" >> { + typeChecks("o_getter.andThen(i_grate)") must beFalse + } + "affold ∘ grate must not compile" >> { + typeChecks("o_affold.andThen(i_grate)") must beFalse + } + "fold ∘ grate must not compile" >> { + typeChecks("o_fold.andThen(i_grate)") must beFalse + } + "modify ∘ grate → ModifyF" >> { + typeChecks("o_modify.andThen(i_grate)") must beTrue + } + "review ∘ grate must not compile" >> { + typeChecks("o_review.andThen(i_grate)") must beFalse + } + "unfold ∘ grate must not compile" >> { + typeChecks("o_unfold.andThen(i_grate)") must beFalse + } + } + + "Grate sub-shape — grate ∘ family (the Grate as outer)" >> { + "grate ∘ iso → MultiFocus[Function1[Int, *]]" >> { + typeChecks("g_box.andThen(i_iso)") must beTrue + } + "grate ∘ lens must not compile" >> { + typeChecks("g_box.andThen(i_lens)") must beFalse + } + "grate ∘ prism must not compile" >> { + typeChecks("g_box.andThen(i_prism)") must beFalse + } + "grate ∘ optional must not compile" >> { + typeChecks("g_box.andThen(i_optional)") must beFalse + } + "grate ∘ trav must not compile" >> { + typeChecks("g_list.andThen(i_each)") must beFalse + } + "grate ∘ getter must not compile" >> { + typeChecks("g_box.andThen(i_getter)") must beFalse + } + "grate ∘ affold must not compile" >> { + typeChecks("g_box.andThen(i_affold)") must beFalse + } + "grate ∘ fold must not compile" >> { + typeChecks("g_list.andThen(i_fold)") must beFalse + } + "grate ∘ modify → ModifyF" >> { + typeChecks("g_box.andThen(i_modify)") must beTrue + } + "grate ∘ review must not compile" >> { + typeChecks("g_box.andThen(i_review)") must beFalse + } + "grate ∘ unfold must not compile" >> { + typeChecks("g_box.andThen(i_unfold)") must beFalse + } + } + + "Grate sub-shape — grate ∘ grate (same carrier)" >> { + "grate ∘ grate → MultiFocus[Function1[Int, *]]" >> { + typeChecks("g_fun.andThen(i_grate)") must beTrue + } + }