Skip to content

feat(core): Unfold — the build-only/many optic citizen - #26

Merged
kryptt merged 12 commits into
mainfrom
feat/unfold-build-many-citizen
Jun 11, 2026
Merged

kryptt merged 12 commits into
mainfrom
feat/unfold-build-many-citizen

Conversation

@kryptt

@kryptt kryptt commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Inhabits the last empty cell of the optic lattice (focus shape × capability): the build-only / many citizen. Unfold[T, B, F] = Optic[Unit, T, Unit, B, Forget[F]] whose real map is

embed :  F[B] => T        -- many → one: the algebra of a recursion scheme

Unfold is to Review exactly what Fold is to Getter — the many-rung build-only mirror. It is the F-shape embed of a Corecursive instance (Fold's S => F[A] being project), and the "assemble one whole from many parts" aggregation arrow that previously had no optic.

Spike + findings: docs/brainstorms/2026-06-10-unfold-build-many-citizen.md (Q1–Q6 all resolved in-doc).

What's new

  • core/optics/Unfold.scala — final class storing embed (per the encoding findings), with two factories:
    • Unfold.apply (F: Applicative) — honest vestigial to = pure(()); read-side ops degrade to the singleton layer (law-pinned).
    • Unfold.algebra (constraint-free) — for pattern functors (BinF, RoseF, …), which admit Traverse but no Applicative (pure cannot pick a constructor). Their vestigial to fails loudly if forced.
  • Fused compositions, mirroring Review's reversibility pattern in both directions:
    • unfold.andThen(review) — pre-process each part (Functor[F] only, pattern functors included)
    • unfold.andThen(unfold) — layered algebras (Applicative[F], the assocForgetMonad pull)
    • unfold.andThen(iso | prism) — generalized reversible-inner overload (ReverseAccessor[G])
    • review.andThen(unfold) fused member + the reversible-outer ∘ Unfold extension (iso/prism.andThen(unfold))
  • direct2forget's ??? is now a sound branch — core's last hole. Unfold made the lifted from reachable (a Monad[F] Forget chain executes it); the Foldable[F] singleton-pick is total on every reachable path because the only F[B] ever fed there is monadicPull's pure(b). Pinned by a behaviour test.
  • laws: UnfoldLaws + UnfoldTests discipline rulesets — constructor-correctness (the GetterLaws pattern), pre/post-compose coherence (a composed algebra cannot drift from its parts), and the vestigial singleton-degradation law (Applicative-only ruleset).
  • Composition matrix → 11 families, 121 cells. The Unfold row/column exactly mirrors Review's: iso/prism/review/unfold compose (import-free, unascribed), everything else is void by design. Notably getter ∘ unfold is void because read-only optics have B = Unit — the feat: recursion schemes as composable optics (+ honest Fold, structure-preserving cross) #23 honesty decision pays off here.
  • schemes: cata overload consuming a pure PSVec algebra carried as an Unfold — algebras can now be assembled by optic composition before the fold engine consumes them.

Key decisions

Testing

  • UnfoldSpec (10 examples): embed, all five composition seams, the formerly-??? morph-routed path, singleton degradation, loud failure for pattern-functor read ops.
  • OpticsLawsSpec: Unfold rulesets for a List carrier (full, incl. vestigial law) and a BinF pattern functor (Functor-only).
  • CompositionMatrixSpec: 121/121 green — all 7 new inhabited cells land Unfold unascribed; all 14 new void cells fail to compile as asserted.
  • SchemesSpec: pure-algebra cata equivalence + an optic-composed algebra driven through the engine.
  • Full root aggregate green; scalafmt/scalafix clean; mdoc + laikaSite build (pre-commit), sbt test (pre-push).

Rename: Setter/SetterF → Modify/ModifyF

With the build group now fully populated (Review / Unfold), the write-only family's old name was ambiguous — a "Setter" sounds like it builds. Renamed to Modify (family) / ModifyF (carrier), making the three one-way rungs unambiguous: read-only (Getter/AffineFold/Fold), build-only (Review/Unfold), write-only (Modify). Laws, tests, matrix, site, and README follow; benchmark class/method names are kept verbatim for CI tracking continuity (Monocle's family is still Setter); no deprecation shims since no published baseline exists.

Docs

  • optics.md: family taxonomy redrawn with all three one-way rungs (read-only Getter→AffineFold→Fold, build-only Review→Unfold, write-only Setter) plus the collapse rules; a full 11-family / 121-cell composition matrix table (mirroring CompositionMatrixSpec) with the void-structure rationale; a new Unfold reference section with mdoc-run examples (both factories, pattern-functor composition).
  • Stale-info sweep (site + scaladoc): ReadOnly[F] → ReadCompose, DirectGetter → Getter (incl. a broken scaladoc link in Lens), AffineFold alias corrected to B = Unit, "lens ∘ affineFold doesn't type-check" corrected (it composes), removed the cross-F ~> Fold extension section (the extension no longer exists), migration guide's "Getter/Setter don't compose" replaced with the collapse story.
  • concepts.md: Direct → Forget[F] edge added to the Composer lattice; carrier table gains Review and Unfold. schemes.md: the algebra-as-an-optic section (cata(Unfold), node-blind honesty note). Review/Unfold ↔ Fold/Getter mirror cross-links added across core scaladocs.

Deferred follow-ups

Plan/spike doc: docs/brainstorms/2026-06-10-unfold-build-many-citizen.md

🤖 Generated with Claude Code

Inhabits the last empty cell of the focus-shape × capability lattice:
Unfold[T, B, F] = Optic[Unit, T, Unit, B, Forget[F]] whose real map is
embed: F[B] => T — the algebra of a recursion scheme and the
"assemble one whole from many parts" arrow, as Review is to Getter on
the many rung (spike: docs/brainstorms/2026-06-10-unfold-build-many-citizen.md).

- core: final-class Unfold with two factories — apply (Applicative
  carriers; honest vestigial to = pure(())) and algebra (pattern
  functors, which admit no Applicative; vestigial to fails loudly).
  Fused andThen members: Unfold∘Review (Functor), Unfold∘Unfold
  (Applicative, algebraic-lens pull), and the generalized
  Unfold ∘ any-reversible-inner (ReverseAccessor[G]).
- core: Review.andThen(Unfold) fused member + the reversible-outer ∘
  Unfold extension — the many-rung mirrors of the Review collapses.
- core: direct2forget's documented-unreachable `???` from is now a
  sound branch (Foldable[F] singleton-pick; the only reachable F[B]
  is monadicPull's pure(b)). Unfold made the branch reachable.
- laws: UnfoldLaws + UnfoldTests — constructor-correctness,
  pre/post-compose coherence, vestigial singleton degradation
  (Applicative-only RuleSet); registered in OpticsLawsSpec for a List
  carrier and a pattern functor.
- tests: composition matrix extended to the 11-family grid (121
  cells); the Unfold row/column mirrors Review's reversibility
  pattern. UnfoldSpec pins behaviour incl. the formerly-??? path.
- schemes: cata overload consuming a pure PSVec algebra carried as an
  Unfold (node-blind by design; the typed path is where pure algebras
  are fully expressive — Embed[F, S] ≅ Unfold[S, S, F], future work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

🚀 Cloudflare Pages preview for feat/unfold-build-many-citizen is live:

https://8ea6a4ec.cats-eo-docs.pages.dev

Branch alias: https://feat-unfold-build-many-citiz.cats-eo-docs.pages.dev

Built from commit f924bb007a1c5b5bdbb28c7c616e12648736b319 · updated on every push.

kryptt and others added 11 commits June 11, 2026 02:14
Site:
- optics.md: family taxonomy redrawn with all three one-way rungs
  (read-only Getter→AffineFold→Fold, build-only Review→Unfold,
  write-only Setter) and the collapse rules; NEW full 11-family /
  121-cell composition matrix table (pinned by CompositionMatrixSpec)
  with the void-structure rationale; NEW Unfold reference section
  (both factories, pattern-functor composition, mdoc-run examples);
  ReadOnly[F] → ReadCompose, DirectGetter → Getter, AffineFold alias
  corrected to B = Unit, "lens ∘ affineFold doesn't type-check"
  corrected (it composes via ReadCompose), removed the cross-F `~>`
  Fold extension section (the extension no longer exists — the
  read-collapse covers it), pointers re-aimed at the spec.
- concepts.md: Direct → Forget[F] edge added to the Composer lattice
  (direct2forget, now with a sound build side); carrier table gains
  Review (Direct) and Unfold (Forget[F]).
- schemes.md: "the algebra as an optic" — cata(Unfold) overload with
  the node-blind honesty note.
- migration-from-monocle.md: "Getter/Setter don't compose" replaced
  with the collapse story; Review/Unfold no-Monocle-equivalent row.
- multifocus.md / index.md / benchmarks.md: matrix pointers freshened,
  DirectGetter → Getter.

Scaladoc:
- Lens: broken [[DirectGetter.andThen]] link → [[Getter.andThen]].
- Optic companion: extension catalogue mentions the Unfold collapse.
- Review / Fold / Forget: cross-links to the Unfold dual.
- GetterLaws: direct2forget parenthetical updated (sound pick, not
  "reachably-unsound-free").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The write-only family's old name overlapped with the build group's
vocabulary: a "Setter" sounds like it sets/constructs, but the family
is the modify capability — `(A => B) => S => T` — with no build side
at all (Review / Unfold own building). Renaming makes the taxonomy's
three one-way rungs unambiguous: read-only (Getter/AffineFold/Fold),
build-only (Review/Unfold), write-only (Modify).

- core: `optics.Setter` → `optics.Modify` (final class + companion),
  `data.SetterF` → `data.ModifyF` (carrier; stored pair accessor
  `setter` → `modifier`); given names follow (`assocModifyF`,
  `tuple2modify`, `either2modify`, `affine2modify`,
  `multifocus2modify`, `coerceToModify`).
- laws: `SetterLaws`/`SetterTests` → `ModifyLaws`/`ModifyTests`
  (RuleSet method `setter` → `modify`); `SetterFLaws`/`SetterFTests`
  → `ModifyFLaws`/`ModifyFTests`. The Monocle provenance note keeps
  pointing at `monocle.law.SetterLaws` (their name).
- tests: composition-matrix row/cells renamed (`modify ∘ …`),
  fixtures `o_setter`/`i_setter` → `o_modify`/`i_modify`; behaviour
  spec titles updated.
- benchmarks: eo-side alias `EoSetter` → `EoModify` and carrier refs
  updated; `SetterBench` CLASS NAME and method names kept verbatim so
  CI benchmark tracking history stays continuous (Monocle's family is
  still `Setter` — `MSetter`/`mSet` untouched).
- site + README: family list, taxonomy diagram, composition matrix,
  carrier tables, Composer lattice, migration guide (Monocle `Setter`
  → eo `Modify` row; left column keeps Monocle's name), anchors
  `#setter` → `#modify`. Also fixed two stale claims found in the
  sweep: concepts.md still said Review sits outside the Optic trait
  (false since the Review→Optic fold-in), and benchmarks.md still
  named `SetterOptic` (a class renamed before this change). README
  family list also gains the missing Unfold entry.

No deprecation shims: no published baseline exists (mima.sbt — 0.1.0
has no previous version), so the rename is a pre-release cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The flat taxonomy flowchart conflated the family space's three
independent axes. Replace the lead diagram with a hand-authored
isometric SVG (static/optic-taxonomy-3d.svg) over:

  x — focus arity      one / 0-or-1 / many
  y — from side        contextual write / total build / none
  z — read side        present (top plane) / absent (bottom plane)

The geometry surfaces facts the flat graph could not: Lens/Iso and
Optional/Prism differ only on the write-vs-build axis (the Modify
rename rationale, now visible); the read-only and build-only rails
run parallel (Fold ↔ Unfold mirror); the (0-or-1, build) cell
collapses into Review (mend is total); the (many, read, total-build)
cell is uninhabited; Modify spans the arity axis as the write-only
bottom.

- Rendered as a pure SVG <img> — no JS, dark mode via an internal
  prefers-color-scheme stylesheet matching the Helium palette;
  verified by headless-browser screenshots in both schemes.
- Generator script committed at site/tools/gen-taxonomy-svg.py
  (deterministic geometry; rerun to regenerate after palette or
  family changes).
- The mermaid graph stays, retitled "Composition joins" — edges are
  about composition joins, which read better in 2D; the matrix table
  remains the cell-level truth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nality; drop the mermaid graph

Redesign the isometric taxonomy around the composition machinery
itself and make it the sole family diagram (the mermaid joins graph
is gone — the matrix table is the cell-level truth, the figure the
geometric intuition):

  x — read cardinality   1 / 0-or-1 / N   (the ReadCompose join axis)
  y — write cardinality  1 / 0-or-1 / N   (a future WriteCompose)
  z — capability         read-only (top), read-write (middle),
                         write/build-only (bottom)

Read-only families collapse to a rail on the read axis (Getter /
AffineFold / Fold — the top layer); write/build-only families to a
rail on the write axis (Review / Unfold + the cardinality-agnostic
Modify bar — the bottom layer); the read-write grid pairs the axes
(Iso·Lens at (1,1), Prism·Optional at (0-or-1,1), Traversal at
(N,N)). Read-collapse = projection up onto the top layer at the
ReadCompose join; write/build-collapse = projection down.

The write-cardinality 0-or-1 cells (fallible write / fallible build)
are rendered as planned (tan, dashed) and pointed at the
failure-typed-build spike; a scope addendum in the BiAffine
brainstorm pulls WriteCompose and the missing-cell carriers into
that plan explicitly.

Verified by headless-browser screenshots (light + dark) of both the
standalone SVG and the built optics.html.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the cardinality axes with the sharper pair (user-designed):

  x — focus nature   total / fallible / multiple   (how `to` lands;
                     exactly the ReadCompose join lattice)
  y — source nature  total / contextual / fallible (what `from`
                     needs: bijection-or-mend / leftover X / can
                     reject — the WriteCompose / BiAffine rung)
  z — capability     read-only / read-write / write-only (unchanged)

The payoff over the cardinality axes: the read-write grid becomes
COMPLETELY inhabited — Iso (total,total), Lens (total,contextual),
Prism (fallible,total), Optional (fallible,contextual), Traversal
spanning (multiple × {total,contextual}) since fixed-shape/Grate
rebuilds totally via tabulate while `each` rebuilds contextually —
and the only remaining row is the planned fallible-source one
(fallible write / BiAffine / fallible each), which maps one-to-one
onto the failure-typed-build spike's candidates. Iso/Lens and
Prism/Optional get their own cells back, and both one-way rails now
run parallel along the focus axis (Review consumes a total focus,
Unfold a multiple one).

BiAffine brainstorm addendum updated to the new axes: with this
basis, every remaining hole in the family space IS that spike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A write-only optic's `from` is real (only `to` is vestigial), so both
axes apply on the bottom layer, and each family sits directly below
the read-write cells whose build half it is: Review below Iso (with
≡ Review below Prism — mend is total), Unfold below Traversal's
total flank, Modify as the entire contextual row (it IS the
contextual write half of Lens / Optional / each), fallible build as
the planned fallible row. The vertical drop now MEANS something:
write-only = the layer above minus its read side. Only the read-only
top layer remains a rail — its `from` is genuinely absent.

Also fixes the previous layout's real error: `fallible build` was
railed between Review and Unfold as if it differed in focus nature;
it differs in source nature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend Getter / AffineFold / Fold into bars across the source axis:
read-only optics pin B = Unit (the terminal type, NOT Nothing), so a
function into Unit exists uniformly for every input and the vestigial
`from` is vacuously satisfied at every source nature — the read-only
families honestly hold the whole axis, the exact dual of Modify
spanning the focus axis on the write-only layer. (Nothing in that
slot would admit no `from` at all.)

With this, all three capability layers are full planes over the same
focus × source footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review is the build half of Iso AND Prism (a mend is total), so it is
one tile spanning both cells on the write-only plane — not a tile
plus a ≡-ghost leaving a visual hole. Bottom plane is now gaplessly
tiled: Review (span) + Unfold on the total row, Modify on the
contextual row, fallible build (planned) on the fallible row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the three integration pages into site/docs/integrations/ with
their own directory.conf (title + order: circe, avro, jsoniter); the
root navigationOrder lists the directory in their old slot. All
relative links rewritten (root pages → integrations/<page>.md, moved
pages → ../<page>.md, intra-integration links unchanged) and the
README's absolute eo.constructive.dev/<page>.html URLs updated to
/integrations/. Page URLs change accordingly (old root-level
circe/avro/jsoniter.html paths are gone).

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

Fact-checked against monocle-core/monocle-law 3.2 (via cellar) and
Maven Central, and corrected accordingly:

- REMOVED "Polymorphic constructors" as a divergence — Monocle ships
  the full PLens/PPrism/POptional/PTraversal/PSetter hierarchy; the
  cheat sheet now maps PLens.apply to Lens.pLens instead.
- REMOVED "Discipline law instances" as a divergence — Monocle ships
  monocle-law with the same discipline rule-set pattern; reframed as
  "law testing ports directly" (an import swap), which is the truth.
- CORRECTED the circe section: the module is circe-optics (not
  "monocle-circe"), it is published for Scala 3, and JsonPath does
  navigate the AST without a full decode. eo's actual differentiators
  stated instead: multi-field foci, the observable Ior failure
  channel, and the extension to Avro records and raw jsoniter bytes.
- Tone: Monocle is never "incapable" or "missing" — phrasings are
  now "prefers", "chooses", "expresses through", "leaves to
  dedicated libraries", with its design goals credited.

Structure per review: a junior-friendly "Why migrate?" opener stating
eo's actual positioning — performance and industrial application;
the internals are not the most elegant code, what you buy is
efficiency, stack safety, interoperability (wire formats), and
compositional reach. Capability content refreshed to current eo
(Unfold, Modify, recursion schemes, the 11-family compiler-pinned
matrix, BijectionIso full-cover upgrade). Cheat sheet moved to the
bottom and extended (pLens row, laws row, schemes row). Benchmark
claims restated within what the published tables support (~2x at
depth 6, ~3x traversals, Monocle wins some single-hop cells).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both subsections claimed differences Monocle doesn't actually have:

- ".andThen auto-morphs" implied cross-family composition is an eo
  feature; Monocle's hierarchy composes across families just as well
  (and the page already said so). Rewritten as "Bring your own
  optic": the real difference is that eo has NO extension hierarchy —
  composition is typeclass-driven over the carrier, so an optic from
  outside the shipped families needs no blessed place in a subtype
  tree: implement the trait, provide the instances, compose with
  everything. Links to Extensibility.
- "Read-only and write-only chains collapse" implied read-only
  collapse is an eo feature; Monocle's hierarchy lands on Getter/Fold
  just as naturally. Rewritten as "The one-way side, built out in
  both directions": what eo actually adds is the fully populated
  build/write-only direction (Modify, Review, Unfold; fallible tier
  planned) and the preliminary recursion-schemes integration —
  schemes as optics so recursive-structure algorithms sit in the same
  pipeline as getters and modifiers, heading toward whole
  request-to-Kafka flows as one composed optic. Leads into the
  family-roster section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kryptt
kryptt merged commit cd884ef into main Jun 11, 2026
12 checks passed
@kryptt
kryptt deleted the feat/unfold-build-many-citizen branch July 5, 2026 14:57
kryptt added a commit that referenced this pull request Sep 4, 2026
…ed upcast, formatting

The concurrent force-push rebased the branch onto a newer main (#26,
Unfold): Miss dropped its second type parameter (Miss[A] extends
Affine[A, Nothing], re-typing subsumed by subtyping, widenB gone).
Adapts the C8/BiAffine-drop commit's touched files to that arity —
Graft[Affine] constructors, Apo.scatter, the spec toys (widenB check
rewritten as the allocation-free upcast it now is) — plus scalafmt on
the drifted files.
kryptt added a commit that referenced this pull request Oct 1, 2026
…ed upcast, formatting

The concurrent force-push rebased the branch onto a newer main (#26,
Unfold): Miss dropped its second type parameter (Miss[A] extends
Affine[A, Nothing], re-typing subsumed by subtyping, widenB gone).
Adapts the C8/BiAffine-drop commit's touched files to that arity —
Graft[Affine] constructors, Apo.scatter, the spec toys (widenB check
rewritten as the allocation-free upcast it now is) — plus scalafmt on
the drifted files.
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