From f706594720ae2b48ea57132b854afd187fa5e923 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 30 Sep 2026 20:50:38 +0200 Subject: [PATCH 1/2] fix(core): compose the Function1 (Grate) carrier positionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mfAssocFunction1` sampled the outer rebuild once and broadcast the result, so `grate ∘ iso` collapsed every position onto index 0 — silently, behind a matrix cell that pins typing only. `MultiFocus.tuple`, `representable` and `apply` all tabulate, so the sampled value was wrong for all three. - `Function1BroadcastOptic[S, T, A, B, X0]` is the witness for the one inner kind whose write has exactly one value to build; the kernel writes it per index (`broadcastFrom`) and reads the inner's bundle per index for every inner. - `Z = Xo`: the outer's leftover is threaded through the composition instead of being forged, so the outer's `from` receives what its own `to` produced. - `unobserved[A]` names the two remaining stand-ins (an uninhabited index type, an inner leftover the kernel cannot produce); the class's own `from` is the only index site left and is guarded by the constant-bundle contract. - `MultiFocusFunction1Spec`: positional read/modify/replace across all three Grate factories, the bundle-inner diagonal read, and the previously mis-titled `.andThen` block fixed. - `GrateShapeSpec`: the Naperian sub-shape's 23-cell footprint (6 composing / 18 structural voids), compile-pinned. - QA page: generated Grate sub-table, a legend that states ✓ is a *typing* claim, and carrier-level caveats; `optics.md` + `multifocus.md` cross-link it and the stale `Z = (Xo, Xi)` claim is corrected. --- .gitignore | 2 + .../dev/constructive/eo/data/MultiFocus.scala | 121 +++++++++++--- .../eo/MultiFocusFunction1Spec.scala | 64 +++++++- site/docs/multifocus.md | 39 ++++- site/docs/optics.md | 15 ++ site/docs/quality-assurance.md | 57 ++++++- site/tools/gen-qa-report.py | 94 ++++++++++- .../dev/constructive/eo/GrateShapeSpec.scala | 151 ++++++++++++++++++ 8 files changed, 500 insertions(+), 43 deletions(-) create mode 100644 tests/src/test/scala/dev/constructive/eo/GrateShapeSpec.scala 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/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala index c1c25fc7..82c13624 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,61 @@ 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 are guarded by construction rather than by a value: + * + * - an **index** into a broadcast bundle — `X0` may have no inhabitant at all + * (`Function1[Nothing, *]`, a phantom slot), and the bundle handed to + * `Function1BroadcastOptic.from` is constant at every index, so no index is ever observed; the + * kernel's write path never takes this route at all (it builds per index through + * `broadcastFrom`). + * - an **inner optic's existential leftover** — `mfAssocFunction1` 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. + * + * `inline` is load-bearing: `null.asInstanceOf[Int]` is `0` at a call site with a primitive index + * 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 either one, 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. + * + * 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]) + 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) + + def from(fb: MultiFocus[Function1[X0, *]][X, B]): T = + // Only reachable on the direct-use path (`modify` / `replace` map this optic's own broadcast, + // so the bundle is constant); the kernel writes per index through `broadcastFrom` instead. + broadcastFrom(MultiFocusK.foci(fb)(unobserved[X0])) + /** 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 +347,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 +583,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 @@ -974,8 +1044,17 @@ 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). + * any X0 (null sentinel). + * + * '''Constant-bundle contract.''' The null-sentinel 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-any-index 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. * * @group Instances */ @@ -984,13 +1063,7 @@ object MultiFocusK: 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) /** 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/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala b/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala index bf6ef0b6..7c480ddf 100644 --- a/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala +++ b/core/src/test/scala/dev/constructive/eo/MultiFocusFunction1Spec.scala @@ -11,6 +11,7 @@ import org.specs2.mutable.Specification import data.MultiFocus import data.MultiFocus.at +import optics.Iso import optics.Optic import optics.Optic.* @@ -25,14 +26,18 @@ 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. * * 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 +97,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)]( @@ -178,9 +182,57 @@ 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)) diff --git a/site/docs/multifocus.md b/site/docs/multifocus.md index 23e6275b..0d4da818 100644 --- a/site/docs/multifocus.md +++ b/site/docs/multifocus.md @@ -271,11 +271,31 @@ 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. + - 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 @@ -359,6 +379,19 @@ 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.) +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 Two end-to-end recipes in the [Cookbook](cookbook.md) cover the 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..da809e9c 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` / `representableAt` / `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` / `representableAt` / `apply`): 6 composing / 18 void cells, pinned by `GrateShapeSpec`. +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..071bc65e 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,60 @@ 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` / `representableAt` / `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 ✗ 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 +319,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..8984ee07 --- /dev/null +++ b/tests/src/test/scala/dev/constructive/eo/GrateShapeSpec.scala @@ -0,0 +1,151 @@ +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` / `representableAt` / `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 +// 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 + } + } From 4e71ecca4e292af4b2e805ee1b8237567b4e0c44 Mon Sep 17 00:00:00 2001 From: Rodolfo Hansen Date: Wed, 30 Sep 2026 21:18:36 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(core):=20supply=20the=20Grate=20bridge?= =?UTF-8?q?'s=20index=20=E2=80=94=20the=20read=20stops=20being=20forged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Direct → MultiFocus[Function1[X0, *]]` bridge's product must read a bundle it did not build (`Function1BroadcastOptic.from`), and its carrier stores only `Unit`. Baseline 81a53d3d left that read at a `null.asInstanceOf[X0]` sentinel guarded by the constant-bundle contract. - `data.RepresentativeIndex[X0]` — the witness: a real index, with canonical instances for the index types the Grate factories fix with one (`Int` → 0 for `tuple` and `apply` over `Function1[Int, *]`, `Boolean` → false, `Unit` → ()), singletons via `ValueOf`, and `at(i)` for everything else. - `Function1BroadcastOptic` stores `at` and reads there; the bridge's given takes the witness, so `iso.andThen(grate)` stays import-free wherever an instance exists and is REFUSED for an uninhabited index type (there is no index to witness, so the read that has no answer is refused rather than forged). - `unobserved` keeps its two remaining sites, both existential leftovers the write path discards; its docstring now states that neither is an index — an index witness cannot stand in for per-position data. - Spec (13 blocks): the witness read is observable (a varying bundle at 0 vs 1 gives -1000 vs -990, white-box), the shipped path is witness-invariant (round trip + `modify`), `grate ∘ iso` stays positional under a non-canonical witness, an algebraic index bridges via a one-line `given`, an unwitnessed index does not bridge (`typeChecks`), the shipped instances name real values, and the Boolean-indexed cell resolves off the companion. - Docs: `multifocus.md` bridge row + composition-limits paragraph, the generated QA grate legend (script + page kept identical), the `core` row of the agent guide, and a CHANGELOG entry. Design note, measurements and the alternative supplies (cats' `Representable` has no representative index; `ValueOf` does not cover `Int`/`Boolean`) in docs/research/2026-09-30-grate-witness-index.md. Also formats `GrateShapeSpec` (the baseline commit left it non-scalafmt-clean). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 29 +++ CLAUDE.md | 2 +- .../dev/constructive/eo/data/MultiFocus.scala | 87 ++++--- .../eo/data/RepresentativeIndex.scala | 67 ++++++ .../eo/MultiFocusFunction1Spec.scala | 183 ++++++++++++++- .../2026-09-30-grate-witness-index.md | 215 ++++++++++++++++++ mima.sbt | 9 + site/docs/multifocus.md | 31 ++- site/docs/quality-assurance.md | 6 +- site/tools/gen-qa-report.py | 10 +- .../dev/constructive/eo/GrateShapeSpec.scala | 19 +- 11 files changed, 611 insertions(+), 47 deletions(-) create mode 100644 core/src/main/scala/dev/constructive/eo/data/RepresentativeIndex.scala create mode 100644 docs/research/2026-09-30-grate-witness-index.md 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 82c13624..0302f9bd 100644 --- a/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala +++ b/core/src/main/scala/dev/constructive/eo/data/MultiFocus.scala @@ -68,22 +68,27 @@ private[eo] trait MultiFocusSingleton[S, T, A, B, X0]: 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 are guarded by construction rather than by a value: + * sites need one, and both stand in for an **existential leftover the write path discards** rather + * than for something anyone could have computed: * - * - an **index** into a broadcast bundle — `X0` may have no inhabitant at all - * (`Function1[Nothing, *]`, a phantom slot), and the bundle handed to - * `Function1BroadcastOptic.from` is constant at every index, so no index is ever observed; the - * kernel's write path never takes this route at all (it builds per index through - * `broadcastFrom`). - * - an **inner optic's existential leftover** — `mfAssocFunction1` threads the OUTER's leftover - * through `Z`, so the inner's has no producer on the write path; every shipped + * - 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. * - * `inline` is load-bearing: `null.asInstanceOf[Int]` is `0` at a call site with a primitive index - * 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 either one, this - * is the line to revisit (see `docs/research/2026-04-23-code-quality-review.md`, finding 4). + * 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] @@ -100,11 +105,17 @@ private[eo] inline def unobserved[A]: A = null.asInstanceOf[A] * 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]) - extends Optic[S, T, A, B, MultiFocus[Function1[X0, *]]]: +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`. @@ -117,10 +128,16 @@ final private[eo] class Function1BroadcastOptic[S, T, A, B, X0](o: Optic[S, T, A 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 = - // Only reachable on the direct-use path (`modify` / `replace` map this optic's own broadcast, - // so the bundle is constant); the kernel writes per index through `broadcastFrom` instead. - broadcastFrom(MultiFocusK.foci(fb)(unobserved[X0])) + 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 @@ -968,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 */ @@ -1044,26 +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). + * 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. * - * '''Constant-bundle contract.''' The null-sentinel 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-any-index 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 Function1BroadcastOptic[S, T, A, B, X0](o) + 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 7c480ddf..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,12 +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.Iso -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: * @@ -29,6 +73,9 @@ import optics.Optic.* * - 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. @@ -143,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 @@ -238,3 +291,127 @@ class MultiFocusFunction1Spec extends Specification with ScalaCheck: 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 0d4da818..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`. | @@ -286,7 +286,11 @@ specialised by `F`: 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. + 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 @@ -373,11 +377,21 @@ 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 @@ -491,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/quality-assurance.md b/site/docs/quality-assurance.md index da809e9c..1672b53d 100644 --- a/site/docs/quality-assurance.md +++ b/site/docs/quality-assurance.md @@ -69,7 +69,7 @@ ascription, that spec goes red. `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` / `representableAt` / `apply`), and its footprint is much +`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 @@ -97,8 +97,8 @@ in both directions. | **unfold** | ✗ | ✗ | | **grate** | ✓ | ✓ | -*Same ✓ / ✗ meaning as the grid above, restricted to the Grate sub-shape (`MultiFocus[Function1[X0, *]]`, the Naperian factories `MultiFocus.tuple` / `representable` / `representableAt` / `apply`): 6 composing / 18 void cells, pinned by `GrateShapeSpec`. -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.* +*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.* diff --git a/site/tools/gen-qa-report.py b/site/tools/gen-qa-report.py index 071bc65e..bbaa3452 100644 --- a/site/tools/gen-qa-report.py +++ b/site/tools/gen-qa-report.py @@ -195,9 +195,17 @@ def gen_grate() -> str: 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` / `representableAt` / `apply`): " + 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 " diff --git a/tests/src/test/scala/dev/constructive/eo/GrateShapeSpec.scala b/tests/src/test/scala/dev/constructive/eo/GrateShapeSpec.scala index 8984ee07..3f3cfbc6 100644 --- a/tests/src/test/scala/dev/constructive/eo/GrateShapeSpec.scala +++ b/tests/src/test/scala/dev/constructive/eo/GrateShapeSpec.scala @@ -7,7 +7,7 @@ package dev.constructive.eo // `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` / `representableAt` / `apply`) +// (`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. @@ -22,6 +22,14 @@ package dev.constructive.eo // 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. // ===================================================================== @@ -40,14 +48,21 @@ object GrateFixtures: 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)) + 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]