Skip to content

feat(core): compose Affine → Getter (Optional/AffineFold andThen Getter = AffineFold) - #25

Merged
kryptt merged 14 commits into
mainfrom
feat/affine-getter-compose
Jun 10, 2026
Merged

kryptt merged 14 commits into
mainfrom
feat/affine-getter-compose

Conversation

@kryptt

@kryptt kryptt commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Composing a partial optic (Optional / AffineFold, the Affine carrier) with a read-only Getter now works and yields an AffineFold (a partial read): getOption the focus, then read it through the getter.

val ageOpt: Optional[Doc, Doc, Int, Int] = Optional(...)
val toStr: DirectGetter[Int, String] = Getter[Int, String](_.toString)

val partialRead: AffineFold[Doc, String] = ageOpt.andThen(toStr)   // "optionally focus Int, then read String"
partialRead.getOption(doc)   // Some("20") on hit, None on miss

Why a downgrade to AffineFold

A read-write/partial outer has back-focus B = A, but a Getter's is Unit — so a Getter inner can never thread through as a writable optic. The read-only downgrade is the only sound result, and it's exactly what you want. It mirrors how a Getter chain composes Getter-to-Getter (a read-only inner collapses the result to read-only).

Implementation

  • Optional (concrete, read-write) andThen Getter — a new fused member on the Optional class, alongside its existing andThen(Optional/Lens/Prism/Iso) members, returning AffineFold[S, C]. Deliberately a member, not an extension: an extension andThen on the Affine receiver perturbed the existing cross-carrier resolution (it broke Affine.andThen(MultiFocus)); a member with a distinct DirectGetter param type is resolved by argument type and leaves every other andThen untouched.
  • AffineFold (read-only) andThen Getter — already composes via the morphed path (Morph[Affine, Direct] → AffineFold); now covered by an explicit test.

Testing

  • OpticsBehaviorSpec: a new case asserting both outers (Optional and AffineFold) compose with a Getter to an AffineFold, hit and miss. 22/22 in that spec.
  • Full sbt test aggregate green (the change touches andThen resolution, so the whole optic + law suite was exercised — the previously-passing Affine.andThen(MultiFocus) case still passes).
  • Docs: a note in optics.md under Optional.

Scope

Independent core optic-algebra capability — does not depend on the typed-recursion-schemes work in #24. The Lens → Getter sibling (read-only downgrade for the Tuple2 carrier) is the obvious parallel and a natural follow-up; this PR does the Affine carrier as requested.

Post-Deploy Monitoring & Validation

No additional operational monitoring required — pure, additive library change (a new fused andThen overload), no runtime/external/deployment impact.

🤖 Generated with Claude Code

…ter = AffineFold)

Composing a partial optic with a read-only Getter now works and yields an
AffineFold (a partial read): getOption the focus, then read it through the
getter. A read-write/partial outer's back-focus B = A can't thread a Getter's
Unit back-focus as a writable optic, so the read-only downgrade is the sound
(and only) result — the mirror of how a Getter chain composes Getter-to-Getter.

- Optional (concrete, read-write) andThen Getter: a new fused member on the
  Optional class (alongside its Optional/Lens/Prism/Iso fused andThens),
  returning AffineFold[S, C]. A member (not an extension) so it doesn't perturb
  the existing cross-carrier andThen resolution (e.g. Affine andThen MultiFocus).
- AffineFold (read-only) andThen Getter: already composes via the morphed path
  (Morph[Affine, Direct] -> AffineFold); covered by the new test.

Tests in OpticsBehaviorSpec (both outers -> AffineFold, hit/miss); docs note in
optics.md. Full `sbt test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

🚀 Cloudflare Pages preview for feat/affine-getter-compose is live:

https://72c4d62c.cats-eo-docs.pages.dev

Branch alias: https://feat-affine-getter-compose.cats-eo-docs.pages.dev

Built from commit 2ae615ec459ce55426f6e1b3c8348cb656ee9080 · updated on every push.

kryptt and others added 13 commits June 10, 2026 13:00
…ed to/from

Two coupled changes, validated by three CI A/B benchmark pairs:

1. Read-only / write-only cross-carrier andThen, routed through the
   Accessor/Morph machinery (no per-class fused overloads):
   - andThenReadOnly: total readers (Accessor[F] — Iso/Getter/Lens)
     collapse to DirectGetter — lens.andThen(getter) = Getter.
   - andThenAffineFold: partial readers (NotGiven[Accessor[F]] —
     Optional/AffineFold/Prism) compose their new `readOnly` projection
     through Morph-to-Affine (rightToLeft for Affine, bothViaAffine for
     Either) — optional/prism.andThen(getter) = AffineFold.
   - andThenSetter: writable carriers morph through Composer[·, SetterF]
     and re-wrap concrete — lens/optional.andThen(setter) = Setter.
   - readOnly: the sound write-forgetting projection (T = B = Unit)
     whose Unit seam makes the partial collapse line up.

2. Optic.to/from encoded as methods (def) instead of stored Function1
   vals, across every optic, carrier, and integration:
   - Construction: −36–41% B/op on composed-lens build (the two eager
     closures per optic are gone).
   - Steady-state: broad small CI wins (AffineFold/Optional/Setter/
     Traversal/Avro/Plated, −5–25%).
   - DirectGetter/Review stay FINAL classes with the function as a val
     param + fused per-site andThen: the abstract-class variant centred
     all leaf dispatch in one shared wrapper body, going megamorphic
     (eoGet_3 +219%, eoAna +17% on CI; PrintInlining showed the
     Function1.apply sites left as virtual calls). Final-class restores
     the old-fused baseline (CI-confirmed, eoGet_3/6 −4%).
   - Big-bodied integration optics (JsoniterPrism) keep def to/from as
     1-call forwarders to private methods so the entry stays a few
     bytes and inlines at every use site instead of blowing the
     caller's C2 budget ("callee is too large" with direct bodies).

ReviewBench: new build-direction mirror of GetterBench (pre-built
depth-0/3/6 reverseGet vs hand-rolled floor) — Review composition was
previously unbenchmarked; composed Reviews now allocate identically to
hand-written builders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AffineFold.apply/select now return a concrete `final class PickFold`
  (stores `pick`, fused getOption + andThen(PickFold/DirectGetter)) —
  removes the shared-anonymous-wrapper shape whose single `pick.apply`
  bytecode site goes megamorphic across instances (the PrintInlining
  finding from the abstract-class DirectGetter regression).
- Optic.readOnly is `inline` (per-call-site splice, same rationale);
  E197 suppressed as the intended behaviour.
- Optic.id returns BijectionIso so identity compositions take the fused
  members instead of the generic carrier path.
- Drop the unused `[X]` type params on MendTearPrism/PickMendPrism
  modify/replace (they shadowed the class's `type X`).
- Composer.direct2either: replace `???` on the uninhabited Left branch
  with Nothing-elimination (`case Left(x) => x`) — total and
  compiler-verified.
- Refresh the Optic companion's operations catalogue (readOnly, cross,
  the andThen collapses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ped build

- Unfold: inhabit the empty build-only/many lattice cell. Flags that the
  proposed `T => F[B]` is Fold-shaped (project/coalgebra) and the genuine
  gap is the dual `embed: F[B] => T` (algebra); ties it to direct2forget's
  `???`, Plated.plate's latent project/embed, and Schemes.cata/ana.
- Failure-typed build: generalize Affine to miss on the build side too.
  Lays out 3 candidate encodings (focus-channel / BiAffine / Ior-carrier),
  the Optic.from signature fork, and the fallibility-as-third-axis question.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ue carriers, matrix spec

Every semantically meaningful optic∘optic pair (80 of 100) now composes via
plain .andThen with no expected-type ascription and no data.*.given imports;
the other 20 are void by design (build through non-invertible, write through
read-only, read through write-only) and are pinned as must-not-compile.

- ReadCompose[FO, FI]: read-side composition across the read/write seam.
  Read-arity lattice (total/partial/many -> Getter/PickFold/ForgetFold[List]),
  keyed on Accessor/PartialAccessor with a low-priority ForgetfulFold
  fallback; instances in its own companion so they are in implicit scope for
  every carrier pair. Surfaced as members (extension fallback does not fire
  after member overload failure): a trait-level any∘read-only overload
  (inner T pinned to Unit, B left free so the same-carrier member stays
  strictly more specific), andThenReadAny on Getter/PickFold/ForgetFold
  (override-rehomed trait overload to break the signature-vs-owner overload
  tie), fused andThen(Getter) on BijectionIso/GetReplaceLens, and a new
  ForgetfulFold[Direct].

- Opaque carriers: ForgetK[F, X, A] / MultiFocusK[F, X, A] (opaque types
  cannot be curried, so uncurried opaque + thin curried alias keeps the
  Forget[F]/MultiFocus[F] carrier spelling). Dealiasing now stops at an
  implicit-scope anchor, so the carrier givens resolve with no import;
  object Forget/MultiFocus re-export the K companions for API and legacy
  import compatibility. Instance member signatures respelled in alias form
  (given-with instances are typed by their anonymous class, and dealiased
  signatures leak through inline extensions). PSVec cats instances moved to
  the PSVec companion — the actual implicit-scope anchor for Functor[PSVec].

- CompositionMatrixSpec: all 100 cells asserted via compiletime.testing
  (compiles unascribed + lands at the expected strength, or does not
  compile), deliberately import-free. Verified against a real-compiler
  all-pairs harness: 100/100 agreement.

Carried from the previous session: Accessors.scala split into
Accessor/PartialAccessor/ReverseAccessor, removal of the dangling
ReadCompose references from the reverted spike, and the schemes.md
DirectGetter -> Getter rename catch-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n-imports

Follow-up cleanup to the opaque-carrier conversion.

- Construct via the public carrier names: MultiFocus(x, fa) (façade apply) and
  Forget(fa) (exported ForgetK.apply); removed the now-dead MultiFocusK.wrap.
  No K-name escapes the two carrier definition files.

- Removed 33 redundant `import data.{MultiFocus,Forget,SetterF,Direct,Affine}.given`
  across core/tests/laws/jsoniter/benchmarks. With every carrier's instances on
  its own companion (opaque types are now implicit-scope anchors; SetterF/Direct/
  Affine always had companions), `Foo[Carrier]` resolves with no import.

- Killed two byte-identical duplicate instances that the explicit imports had
  been masking (imported givens outrank implicit-scope ones): the Affine
  ForgetfulTraverse and ForgetfulFold now live ONLY on object Affine
  (Affine.traverse / Affine.fold), matching the carrier-owns convention used by
  Direct/SetterF/MultiFocus/Forget. object ForgetfulTraverse / ForgetfulFold now
  hold only the stdlib carriers (Tuple2, Either) whose companions can't be
  extended. This removes the latent ambiguity that surfaced once the imports were
  gone, so no call site needs to disambiguate anymore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Its only consumer, NamedTupleCodecSpike.scala, was deleted in 100b706; the
macro object referenced it solely in its own docstring. Dead spike code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ce-review summaries and similar agent-tooling output land under .context/;
they are per-run scratch, not project sources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blank by default, so push / default dispatches are unchanged. Lets a manual
run request `-prof gc` to capture allocation B/op — the metric that survives a
noisy shared runner — without the filter-injection trick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…adCompose

The ReadCompose typeclass + the trait-level any∘read-only andThen overload form
a correct generic for the whole read-collapse triangle, demoting the bespoke
read-collapse members from load-bearing-for-compilation to perf fast-paths over
a correct generic. Removed the redundant ones:

- Tier A (generic, strictly dominated by the trait member): the two
  Optic.* extension `andThen(Getter)` collapses (Accessor -> Getter,
  PartialAccessor -> PickFold). A member always beats an extension, so these
  were already shadowed; they merged "two competing generic read-collapse
  paths" into the single ReadCompose path.
- Tier B (single-seam terminal bespoke read-collapses): Prism∘Getter,
  PickMendPrism∘Getter, Optional∘Getter.
- Tier C (the hot-path fast-paths added with ReadCompose): Iso∘Getter,
  Lens∘Getter, Getter∘PickFold, AffineFold∘Getter.

All correctness-neutral: CompositionMatrixSpec stays 100/100 (ReadCompose
returns the identical concrete type for every removed cell), full suite green,
benchmarks compile. The KEPT fusions are the ones the generic cannot replace:
same-family same-carrier (dodge the AssociativeFunctor round-trip + C2's
recursive-inline cap on deep chains) and cross-family structure-preserving
(writable concrete results; Morph would erase the type). The read-collapse
result is single-seam terminal (B=Unit, can't extend the write side), so the
removed members were never a deep-chain spine — the only cost is the fixed,
depth-independent ReadCompose dispatch tax, to be characterised on CI bench.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…forgetful, compose packages

The 11 typeclasses that lived directly in `dev.constructive.eo` (and the
accessor trio that was awkwardly in `eo.data`) now sit in three packages by
responsibility:

- eo.accessor   — Accessor, PartialAccessor, ReverseAccessor
                  (focus extract / inject primitives)
- eo.forgetful  — ForgetfulFunctor, ForgetfulApplicative, ForgetfulFold,
                  ForgetfulTraverse (cats-lifted focus operations)
- eo.compose    — AssociativeFunctor, Composer, Morph, ReadCompose
                  (optic-combination machinery; all import Optic)

The accessor/forgetful boundary is the read-primitive vs cats-operation split;
the compose boundary is objective — those four are exactly the typeclasses that
depend on Optic. Choice B of the three layouts considered.

These were visible to optics/ and data/ for free via enclosing-package scope
and to downstream modules via `import dev.constructive.eo.X`; the move makes
every use site import explicitly. No behaviour change — pure namespacing.
Full suite green, CompositionMatrixSpec still 100/100, benchmarks compile.
Doc snippets (optics.md, multifocus.md) repointed too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kryptt
kryptt merged commit 2ae615e into main Jun 10, 2026
9 of 10 checks passed
@kryptt
kryptt deleted the feat/affine-getter-compose branch June 10, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant