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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ on:
description: "Warmup iterations (-wi)"
required: false
default: "3"
prof:
description: "JMH profiler (-prof), e.g. 'gc' for allocation B/op; blank = none"
required: false
default: ""

permissions:
contents: read
Expand Down Expand Up @@ -65,9 +69,12 @@ jobs:
# NB: under sbt, JMH's working dir is the `benchmarks/` subproject, so
# `-rff jmh-results.json` writes to `benchmarks/jmh-results.json` at the
# repo level — which the summary / upload steps below read.
# `inputs.prof` adds a JMH profiler (e.g. `-prof gc` for allocation
# B/op — the metric that survives a noisy shared runner). Blank emits
# nothing, so the default / push path is unchanged.
run: |
sbt -batch -Dsbt.color=false \
"benchmarks/Jmh/run -i ${{ inputs.iterations || '5' }} -wi ${{ inputs.warmups || '3' }} -f ${{ inputs.forks || '3' }} -t 1 -foe true -rf json -rff jmh-results.json ${{ inputs.filter || '(OrderCirceBench|OrderJsoniterBench|OrderAvroBench).*' }}"
"benchmarks/Jmh/run -i ${{ inputs.iterations || '5' }} -wi ${{ inputs.warmups || '3' }} -f ${{ inputs.forks || '3' }} -t 1 -foe true ${{ inputs.prof != '' && format('-prof {0}', inputs.prof) || '' }} -rf json -rff jmh-results.json ${{ inputs.filter || '(OrderCirceBench|OrderJsoniterBench|OrderAvroBench).*' }}"

- name: Render summary
if: always()
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
# Skills are checked-in agent guidance — track them across the .claude/
# blanket ignore for per-user worktree / scheduling state.
!.claude/skills/
# Compound-engineering tooling scratch (ce-review summaries, etc.)
.context/
.envrc
.envrc.local
.idea/
Expand All @@ -22,3 +24,4 @@ site/target/

# wrangler's local Pages state
.wrangler/
.#*
10 changes: 5 additions & 5 deletions avro/src/main/scala/dev/constructive/eo/avro/AvroPrism.scala
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ final class AvroPrism[A] private[avro] (

// ---- Abstract Optic members ---------------------------------------

def to: IndexedRecord => Either[(AvroFailure, IndexedRecord), A] = record =>
def to(record: IndexedRecord): Either[(AvroFailure, IndexedRecord), A] =
focus.navigateRaw(record) match
case Left(failure) => Left((failure, record))
case Right(any) =>
Expand All @@ -68,10 +68,10 @@ final class AvroPrism[A] private[avro] (
case Left(t) =>
Left((AvroFailure.DecodeFailed(AvroWalk.terminalOf(path), t), record))

def from: Either[(AvroFailure, IndexedRecord), A] => IndexedRecord = {
case Left((_, record)) => record
case Right(a) => reverseGet(a)
}
def from(e: Either[(AvroFailure, IndexedRecord), A]): IndexedRecord =
e match
case Left((_, record)) => record
case Right(a) => reverseGet(a)

// ---- Read surface --------------------------------------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import dev.constructive.eo.bench.fixture.*
*
* EO `Getter`s are `Optic[S, Unit, A, Unit, Direct]` — both the leftover `T` and the back-focus
* `B` are `Unit`, so they compose through the ordinary `andThen` (the fused
* `DirectGetter.andThen`), just like `Iso` / `Lens`. The depth-3 / depth-6 rows therefore build a
* `Getter.andThen`), just like `Iso` / `Lens`. The depth-3 / depth-6 rows therefore build a
* *composed* `Getter` on both sides (EO's `g3.andThen(g2)…` vs Monocle's `mG3.andThen(mG2)…`) and
* dispatch through it once — an apples-to-apples comparison, not EO's hand-nested `get` chain
* against Monocle's composed reader.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import java.util.concurrent.TimeUnit
import com.github.plokhotnyuk.jsoniter_scala.core.writeToArray

import dev.constructive.eo.bench.fixture.*
import dev.constructive.eo.data.MultiFocus.given

import io.circe.Json
import io.circe.parser.parse as circeParse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import cats.instances.list.*

import dev.constructive.eo.bench.fixture.*
import dev.constructive.eo.data.MultiFocus
import dev.constructive.eo.data.MultiFocus.given

/** `MultiFocus[List]` (via `fromLensF`) vs `MultiFocus[PSVec]` (via `Traversal.each[List, _]`) on
* the same `Lens[Order, List[LineItem]] → inner → Lens[LineItem, Int]` chain over the canonical
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import java.util.concurrent.TimeUnit
import cats.data.{Const, ZipList}

import dev.constructive.eo.data.MultiFocus
import dev.constructive.eo.data.MultiFocus.given
import dev.constructive.eo.data.MultiFocus.{collectList, collectMap}
import dev.constructive.eo.optics.Optic

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import dev.constructive.eo.optics.{
Traversal => EoTraversal,
}
import dev.constructive.eo.data.{MultiFocus, PSVec}
import dev.constructive.eo.data.MultiFocus.given

import cats.instances.list.given

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import java.util.concurrent.TimeUnit
import com.github.plokhotnyuk.jsoniter_scala.core.{readFromArray, writeToArray}

import dev.constructive.eo.bench.fixture.*
import dev.constructive.eo.data.MultiFocus.given

/** Canonical-schema jsoniter bench (plan 009, Phase 1).
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import java.util.concurrent.TimeUnit
import cats.instances.arraySeq.*

import dev.constructive.eo.bench.fixture.{Person, Phone}
import dev.constructive.eo.data.MultiFocus.given

import monocle.{Lens => MLens, Traversal => MTraversal}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import cats.instances.arraySeq.given
import cats.instances.list.given

import dev.constructive.eo.bench.fixture.{Company, Department, Employee}
import dev.constructive.eo.data.MultiFocus.given

import monocle.{Lens => MLens, Traversal => MTraversal}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import java.util.concurrent.TimeUnit
import cats.instances.arraySeq.given

import dev.constructive.eo.bench.fixture.Result
import dev.constructive.eo.data.MultiFocus.given

import monocle.{Prism => MPrism, Traversal => MTraversal}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package dev.constructive.eo
package bench

import org.openjdk.jmh.annotations.*
import java.util.concurrent.TimeUnit

import dev.constructive.eo.bench.fixture.*

/** `Review.reverseGet` at the leaf plus deep composition — the BUILD-direction mirror of
* [[GetterBench]], and the coverage the fused [[dev.constructive.eo.optics.Review.andThen]] needs.
*
* EO `Review`s are `Optic[Unit, S, Unit, A, Direct]` — vestigial `to`, a real `from`/`reverseGet`
* that builds `S` from the focus `A`. They compose through the fused `Review.andThen(Review)` just
* as `Getter`s do, so the depth-3 / depth-6 rows build a *composed* `Review` **once, outside the
* hot path** (`private val`, so construction happens at fork start, not per-invocation — this is
* the construction/usage split [[CompositionBench]] measures the other half of), then dispatch
* `reverseGet` through it. Monocle has no `Review`, so the peer is a hand-written builder (the
* floor: plain nested `new`s, no optic).
*/
@State(Scope.Benchmark)
@BenchmarkMode(Array(Mode.AverageTime))
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(3)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
class ReviewBench extends JmhDefaults:

import NestedOptics.{eoR1, eoR2, eoR3, eoR4, eoR5, eoR6, eoRValue}

// Composed Reviews built once at fork start (out of the measured method), so
// each @Benchmark isolates `reverseGet` dispatch from the per-`andThen`
// construction cost CompositionBench measures separately.
private val eoReview3 = eoR3.andThen(eoR2).andThen(eoR1).andThen(eoRValue)

private val eoReview6 =
eoR6.andThen(eoR5).andThen(eoR4).andThen(eoR3).andThen(eoR2).andThen(eoR1).andThen(eoRValue)

private val seed: Int = 42

@Benchmark def eoReverseGet_0: Nested0 = eoRValue.reverseGet(seed)
@Benchmark def eoReverseGet_3: Nested3 = eoReview3.reverseGet(seed)
@Benchmark def eoReverseGet_6: Nested6 = eoReview6.reverseGet(seed)

// Floor: the same nodes built by hand, no optic.
@Benchmark def naiveBuild_0: Nested0 = Nested0(seed, None, Nil)

@Benchmark def naiveBuild_3: Nested3 =
Nested3(Nested2(Nested1(Nested0(seed, None, Nil))))

@Benchmark def naiveBuild_6: Nested6 =
Nested6(Nested5(Nested4(Nested3(Nested2(Nested1(Nested0(seed, None, Nil)))))))
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package bench
package fixture

import dev.constructive.eo.circe.{JsonFailure, JsonPrism, codecPrism}
import dev.constructive.eo.data.MultiFocus.given
import dev.constructive.eo.optics.{Lens => EoLens, Traversal => EoTraversal}

import cats.data.{Chain, Ior}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import dev.constructive.eo.optics.{
Getter => EoGetter,
Lens => EoLens,
Optional => EoOptional,
Review => EoReview,
Setter => EoSetter,
}

Expand Down Expand Up @@ -150,3 +151,20 @@ object NestedOptics:

val mSet6: MSetter[Nested6, Int] =
mS6.andThen(mS5).andThen(mS4).andThen(mS3).andThen(mS2).andThen(mS1).andThen(mSetValue)

// ---- Per-level EO Reviews (Direct carrier, BUILD direction) -----
//
// The mirror of the Getters: a Review[Nested(k), Nested(k-1)] builds the
// parent node from a child (`reverseGet`), and the leaf Review[Nested0, Int]
// builds a populated leaf from an Int. Composed via the fused
// `Review.andThen(Review)`, the depth-3 / depth-6 chains build a whole
// Nested(k) from a single Int. Monocle ships no Review peer, so ReviewBench
// compares against a hand-written builder (the floor) instead.

val eoRValue = EoReview[Nested0, Int](i => Nested0(i, None, Nil))
val eoR1 = EoReview[Nested1, Nested0](Nested1(_))
val eoR2 = EoReview[Nested2, Nested1](Nested2(_))
val eoR3 = EoReview[Nested3, Nested2](Nested3(_))
val eoR4 = EoReview[Nested4, Nested3](Nested4(_))
val eoR5 = EoReview[Nested5, Nested4](Nested5(_))
val eoR6 = EoReview[Nested6, Nested5](Nested6(_))
10 changes: 5 additions & 5 deletions circe/src/main/scala/dev/constructive/eo/circe/JsonPrism.scala
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,16 @@ final class JsonPrism[A] private[circe] (

// ---- Abstract Optic members ---------------------------------------

def to: Json => Either[(DecodingFailure, HCursor), A] = json =>
def to(json: Json): Either[(DecodingFailure, HCursor), A] =
val c = focus.navigateCursor(json)
focus.decodeFromCursor(c) match
case Right(a) => Right(a)
case Left(df) => Left((df, json.hcursor))

def from: Either[(DecodingFailure, HCursor), A] => Json = {
case Left((_, hc)) => hc.top.getOrElse(hc.value)
case Right(a) => focus.encoder(a)
}
def from(e: Either[(DecodingFailure, HCursor), A]): Json =
e match
case Left((_, hc)) => hc.top.getOrElse(hc.value)
case Right(a) => focus.encoder(a)

// ---- Read surface (single-focus specific) -------------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,7 @@ class CrossCarrierCompositionSpec extends Specification:
"(6) JsonPrism → MultiFocus[List]: Composer summons + chain compiles" >> {
import cats.instances.list.given // Alternative[List] + Foldable[List]
import dev.constructive.eo.data.MultiFocus
import dev.constructive.eo.data.MultiFocus.given // mfFunctor + either2multifocus
import dev.constructive.eo.Composer
import dev.constructive.eo.compose.Composer

// Type-level: the Composer summons cleanly under List's Alternative+Foldable.
val composerOk = summon[Composer[Either, MultiFocus[List]]] != null
Expand All @@ -195,8 +194,8 @@ class CrossCarrierCompositionSpec extends Specification:
val tagsMF: Optic[List[Int], List[Int], List[Int], List[Int], MultiFocus[List]] =
new Optic[List[Int], List[Int], List[Int], List[Int], MultiFocus[List]]:
type X = Unit
val to: List[Int] => (Unit, List[List[Int]]) = xs => ((), List(xs))
val from: ((Unit, List[List[Int]])) => List[Int] = { case (_, xss) => xss.head }
def to(xs: List[Int]): MultiFocus[List][X, List[Int]] = MultiFocus((), List(xs))
def from(pair: MultiFocus[List][X, List[Int]]): List[Int] = pair.foci.head

// The cross-carrier .andThen resolves through `either2multifocus[List]`.
val chain: Optic[Json, Json, List[Int], List[Int], MultiFocus[List]] =
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package dev.constructive.eo
package data
package accessor

/** Read the focus `A` out of a two-parameter carrier `F[X, A]`. Required by `Optic.get`;
* always-hitting carriers (`Tuple2` for Lens, `Direct` for Iso / Getter) supply one.
Expand All @@ -16,18 +16,3 @@ object Accessor:
/** `Tuple2` — picks the right element. @group Instances */
given tupleAccessor: Accessor[Tuple2] with
def get[X, A](fa: (X, A)): A = fa._2

/** Dual of [[Accessor]] — build a fresh `F[X, A]` from an `A`. Required by `Optic.reverseGet`.
*
* @tparam F
* the carrier
*/
trait ReverseAccessor[F[_, _]]:
def reverseGet[X, A](a: A): F[X, A]

/** Typeclass instances for [[ReverseAccessor]]. */
object ReverseAccessor:

/** `Either` — wraps `A` as `Right[X, A](a)`. @group Instances */
given eitherRevAccessor: ReverseAccessor[Either] with
def reverseGet[X, A](a: A): Either[X, A] = Right[X, A](a)
Loading
Loading