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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@ jobs:
run: sbt +update

- name: Check formatting
run: sbt '++ ${{ matrix.scala }}' scalafmtCheckAll scalafmtSbtCheck benchmarks/scalafmtCheck
run: sbt '++ ${{ matrix.scala }}' scalafmtCheckAll scalafmtSbtCheck benchmarks/scalafmtCheck demoUplcJvm/scalafmtCheckAll demoUplcJvmValidators/scalafmtCheckAll demoUplcJvmBench/scalafmtCheckAll

- name: Check scalafix
run: sbt '++ ${{ matrix.scala }}' 'scalafixAll --check'
run: sbt '++ ${{ matrix.scala }}' 'scalafixAll --check' 'demoUplcJvm/scalafixAll --check' 'demoUplcJvmValidators/scalafixAll --check' 'demoUplcJvmBench/scalafixAll --check'

- name: Check that workflows are up to date
run: sbt githubWorkflowCheck
Expand All @@ -133,13 +133,16 @@ jobs:
if: matrix.java == 'temurin@25' && matrix.os == 'ubuntu-22.04'
run: sbt '++ ${{ matrix.scala }}' doc

- name: Test UPLC → JVM demo (differential vs Scalus CEK)
run: sbt '++ ${{ matrix.scala }}' demoUplcJvm/test demoUplcJvmBench/Jmh/compile

- name: Make target directories
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v'))
run: mkdir -p jsoniter/target benchmarks/target kyo/target target circe/target zio/target unidocs/target avro/target laws/target tests/target generics/target schemes-laws/target schemes/target core/target project/target
run: mkdir -p jsoniter/target benchmarks/target kyo/target target demos/uplc-jvm-bench/target circe/target zio/target unidocs/target avro/target laws/target tests/target generics/target demos/uplc-jvm-validators/target schemes-laws/target schemes/target core/target demos/uplc-jvm/target project/target

- name: Compress target directories
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v'))
run: tar cf targets.tar jsoniter/target benchmarks/target kyo/target target circe/target zio/target unidocs/target avro/target laws/target tests/target generics/target schemes-laws/target schemes/target core/target project/target
run: tar cf targets.tar jsoniter/target benchmarks/target kyo/target target demos/uplc-jvm-bench/target circe/target zio/target unidocs/target avro/target laws/target tests/target generics/target demos/uplc-jvm-validators/target schemes-laws/target schemes/target core/target demos/uplc-jvm/target project/target

- name: Upload target directories
if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v'))
Expand Down
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,19 @@ Test-only: `org.typelevel:discipline-specs2_3:2.0.0`.
| `zio` | `zio/` | `cats-eo-zio` | ZIO 2 integration: `ZEnvironment` service lens, capability-driven `Ref` focus ops, `ZLayer` projection through `CanGet`; STM focus ops on `TRef`/`TMap` (USTM-returning — multi-focus updates compose into one transaction); `Chunks.each/at/eachNonEmpty` constructors (private Traverse adapters — the orphan given belongs to zio-interop-cats). Three `% Optional` sub-packages (callers add the artifact, avro/circe/kyo pattern): `eo.zio.schema` — `EoAccessorBuilder` fills zio-schema's own AccessorBuilder extension point (Lens per field / Prism per case / Traversal per collection, no macros), `DynamicValues` untyped-tree kit (constructor prisms + generic `primitive(st)`, `field`/`at`/`key`/`variant`/`each`, `Plated`), `schema.dynamicPrism` typed↔untyped, `BinaryCodec.prism` byte face (json/protobuf/avro/msgpack/thrift); `eo.zio.json` — circe playbook on `zio.json.ast.Json` (`JsonValues` prisms + navigation + `Plated` + `text` String↔Json face, `JsonCodec.stringPrism`) plus `JsonCursor.optional` bridging zio-json's own typed paths; `eo.zio.prelude` — `Validations.success` (Optional; prism would drop the log) + `eachFailure` (error-polymorphic traversal). Cross-format spec fuel: zio-schema-derivation + zio-schema-json are Test-scope |
| `kyo` | `kyo/` | `cats-eo-kyo` | Kyo integration (kyo-prelude 1.0.0-RC6 — kyo RC5+ ships Java-25-only bytecode, so this module REQUIRES JDK 25 and leaves the root aggregate on older JVMs; CI's primary lane is 25): `TypeMap` service lens, `Env.focus` / `Layer.focus`, `Var` focus ops; `Record.iso[T]` (quoted macro, NamedTuple OR case class ↔ `Record`, no arity ceiling) + `Record.lens[F]("name")` field lenses via `Fields.Have`; `StructureValues.field`/`key` target the FIRST match (Chunk-backed ⇒ duplicates representable; write-all breaks `modify(identity)`), and `atField` is the At-shaped create/delete optic — lawful up to field ORDER only (delete destroys the position a later insert would restore, so put-put fails; `NavigationTests` yes, `SeamTests` no — the zio kits hide the same behaviour behind order-insensitive equality); the `eo.kyo.schema` sub-package bridges kyo-schema (`% Optional` — callers add it themselves, avro/circe pattern): `Focus` → Lens/Optional, `Schema.prism[Codec]` / `stringPrism` byte-face prisms, and `StructureValues` — optics over the untyped `Structure.Value` tree (constructor prisms, `field`/`at`/`key`/`each`/`variant` navigation, `Plated[Value]`, `Schema.valuePrism` typed↔untyped; `variant` accepts both the declared `VariantCase` spelling AND the single-field wrapper record RC6's encoder actually emits). Module opts out of `-Yexplicit-nulls` (kyo's inline kernel isn't nulls-clean) and `-Wconf`-silences kyo-schema-origin warnings |
| `benchmarks` | `benchmarks/` | — (not published) | JMH benchmarks vs Monocle (not part of root aggregate) |
| `demoUplcJvm` | `demos/uplc-jvm/` | — (not published) | Demo: UPLC (Cardano Plutus Core) → JVM bytecode as a chain of LAWFUL monomorphic eo optics, `CborScript ⇀ TypedProgram ≅ JvmClass ⇀ JvmClassScript` (stages `toTyped`, `lowering`, `compiling` — the last is `UplcJvm.compile` / `decompile`; callers use `UplcJvm.toTyped` / `toJvmClass` / `toBytecode`, all from the script, all `MendTearPrism`s). Two PRISMS around one iso: Scalus decodes ANY declared Plutus Core version, but the ledger takes only those Plutus V3 has at PV11 (`Semantics.versions` = 1.0.0, 1.1.0), so `toTyped` misses on any other; a class can exceed the JVM's 64 KB method / 65 535-constant limits, so `compiling` misses (`UplcJvm.compile` gives the limit as a `Left` — the one catch of ASM's `MethodTooLargeException` / `ClassTooLargeException`). `lowering`'s way back types the embedded script WITHOUT the version check (only a typed program is lowered). Lawful because every stage keeps its script — `TypedProgram` its source, the class the script as Base64 constants — so `reverseGet` walks back exactly; `OpticSpec` runs cats-eo-laws' `PrismTests` on `toTyped` and `toBytecode` (JvmClass has no equality, so no `IsoTests` for `lowering`); tests reach stages through `Oracle.typed` / `jvmClass` / `classFile`, and `Oracle.compare` checks the round trips on every program. The four types hand out COPIES only (`bytes`, `tree`), so nothing changes a value after the compiler made or checked it. Compile time is STACK SAFE (`OpticSpec` compiles 100 000-deep programs on a 1 MB stack): decode, `Typer` and `Lower` are walks of `Step`s (`Step.scala`) — plain calls while shallow, `cats.Eval` on the heap from `Walk.Deep` (128) calls deep — so EVERY recursive call must go through `Walk.nested`. Trampolining every node (plain `Eval`) made translation ~2× slower. Steps run once each, in order, so `Lower` writes instructions as a step is made. Never sequence effectful `Eval`s with cats' `traverse_` / `void`: cats treats `Eval` as pure and drops a step whose result is unused. Scalus' flat decoder/encoder still recurse (overflow at 2 000–20 000 deep on 1 MB). `CborScript` / `JvmClassScript` are opaque over Scalus' `ByteString` (value equality, so the laws can use `==`), `JvmClass` opaque over ASM's `ClassNode` (made only by `Lower` and `UplcJvm.decompile`; `.tree` to inspect); `CborScript.parse` is the checked entry for untrusted bytes. Compiled with `-opt -opt-inline:**,!scala.**`: inlining from the Scala library made the short validations 12–27% slower (it grows `Rt.force` past what the JIT inlines), everything else measured neutral to ~5% faster. Keep generated code free of unreachable instructions (the typer drops what follows a failure): dead code leaves constant-pool entries that break the class file's read/write fixpoint. TypedProgram = typed Scala-like expression over Scalus' own `Term`s (hybrid typing: types are Scalus `DefaultUni`s, builtin signatures its `TypeScheme`s, instantiated at argument types; unboxed `BigInt`/`boolean`/`ByteString`/`String` where provable, boxed Scalus `Constant`s elsewhere), JvmClass written by asm-commons' `InstructionAdapter` (no own IR — and not a library above ASM: those cost translation speed), class file by ASM `ClassWriter` (computes frames). Saturated builtins are direct JVM calls of `scalus.uplc.builtin.Builtins` (or lazy-`Data` natives in `Rt`); the rest, and builtins used as values, run Scalus' CEK meanings. Reuse Scalus code before adding demo-local code: failures are `MachineError`, `Exec.run` returns `eval.Result`. Correctness is DIFFERENTIAL against the Scalus 1.3.0 CEK machine (Plutus V3 @ PV11, variant E — `Semantics.scala`): vendored Plutus conformance suite (`src/test/resources/plutus-conformance`, regenerate with `scripts/refresh-conformance.sh`, never hand-edit) + validators. Result/error only — no budget metering. Not in the root aggregate (Scalus pulls JNI crypto); CI runs `demoUplcJvm/test` as its own step. See its README |
| `demoUplcJvmValidators` | `demos/uplc-jvm-validators/` | — (not published) | On-chain validators compiled to UPLC by `scalus-plugin` (isolated so the plugin never sees the compiler's sources), plus the shared off-chain fixtures (`Contexts`, `Scenarios`); test-scope dep of `demoUplcJvm` |
| `demoUplcJvmBench` | `demos/uplc-jvm-bench/` | — (not published) | JMH for the demo: CEK machine vs generated class per validator, Fibonacci scaling, translation cost. NOT part of `benchmarks/`, the nightly sweep or `BENCHMARKS.md`; its numbers live on `site/docs/demos/uplc-jvm.md`. CI only compiles it (`demoUplcJvmBench/Jmh/compile`) |

The root project aggregates `core`, `laws`, `tests`, `generics`, `schemes`,
`schemesLaws`, `circe`, `avro`, `jsoniter`, `zio`, and `kyo`. `sbt compile` and `sbt test`
cover those; benchmarks must be invoked explicitly (see below).
cover those; benchmarks and the `demoUplcJvm` demo must be invoked explicitly
(see below; `sbt demoUplcJvm/test`).

Published modules compile with Scala 3's bytecode optimizer (`scala3LibrarySettings` in
`build.sbt`): `-opt`, plus `-opt-inline:dev.constructive.eo.**` — the inliner may copy code
only from eo's own packages, never from cats, the JDK or the Scala library, whose next
releases would leave stale copies in our jars.

## Toolchain

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ for cats typeclasses.
- ZIO integration: <https://eo.constructive.dev/integrations/zio.html>
- Kyo integration (Records, kyo-schema bridge): <https://eo.constructive.dev/integrations/kyo.html>
- Cookbook (recipes): <https://eo.constructive.dev/cookbook.html>
- Demo — a UPLC (Cardano Plutus Core) → JVM bytecode compiler built as one
eo `Iso`, checked against the Scalus CEK machine:
[`demos/uplc-jvm`](./demos/uplc-jvm/README.md)
- Benchmarks vs Monocle: [`BENCHMARKS.md`](./BENCHMARKS.md) (generated
each sweep; B/op is the comparable metric, ns/op is directional)
- Composition gap analysis (research):
Expand Down
113 changes: 94 additions & 19 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,31 @@ ThisBuild / githubWorkflowBuildPreamble ++= Seq(
"scalafmtCheckAll",
"scalafmtSbtCheck",
"benchmarks/scalafmtCheck",
"demoUplcJvm/scalafmtCheckAll",
"demoUplcJvmValidators/scalafmtCheckAll",
"demoUplcJvmBench/scalafmtCheckAll",
),
name = Some("Check formatting"),
),
WorkflowStep.Sbt(
List("scalafixAll --check"),
List(
"scalafixAll --check",
"demoUplcJvm/scalafixAll --check",
"demoUplcJvmValidators/scalafixAll --check",
"demoUplcJvmBench/scalafixAll --check",
),
name = Some("Check scalafix"),
),
)

// The UPLC → JVM demo sits outside the root aggregate (Scalus drags in JNI
// crypto bindings, bouncycastle, borer, upickle, monocle), so `sbt test` and
// the pre-push hook stay lean; CI runs its differential suite as its own step.
ThisBuild / githubWorkflowBuild += WorkflowStep.Sbt(
List("demoUplcJvm/test", "demoUplcJvmBench/Jmh/compile"),
name = Some("Test UPLC → JVM demo (differential vs Scalus CEK)"),
)

// -------------------------------------------------------------------
// Scalafix wiring. SemanticDB exports are required by the semantic
// rules (RemoveUnused, OrganizeImports). Rule set lives in
Expand Down Expand Up @@ -520,10 +536,11 @@ lazy val commonSettings = Seq(
// method (box elim, null-check folding, dead-code elim, copy
// propagation) and touches neither TASTy nor call boundaries, so
// downstream binary-compat checks see nothing. `-opt-inline` DOES cross
// method/class boundaries and is reserved for [[scala3CoreSettings]]
// only, with the `<sources>` scope so nothing from cats / JDK is baked
// into our jar (a future cats release can't be invalidated by stale
// inline copies). Laws / generics / circe stay on `-opt` only.
// method/class boundaries, so it is scoped to this library's own
// packages, `dev.constructive.eo.**`: nothing from cats / the JDK / the
// Scala library is baked into our jars (a future cats release can't be
// invalidated by stale inline copies), while one eo module may inline
// another's code, as all of them are released together.
//
// `-Yretain-trees` keeps full ASTs in published TASTy so downstream
// callers' `inline def` expansions can see through our bodies.
Expand All @@ -539,6 +556,7 @@ lazy val commonSettings = Seq(
lazy val scala3LibrarySettings = Seq(
scalacOptions ++= Seq(
"-opt", // method-local JVM bytecode optimizer
"-opt-inline:dev.constructive.eo.**", // inline across methods, from eo's own code only
"-Yretain-trees", // downstream `inline def` / macros see our bodies
"-Wsafe-init", // catch `val` init-order bugs in companion objects
"-Yexplicit-nulls", // non-nullable reference types by default
Expand All @@ -552,19 +570,6 @@ lazy val scala3LibrarySettings = Seq(
)
)

// Pure library modules with no dependency on another artifact's
// internals can additionally enable `-opt-inline:<sources>` — inlines
// only between files compiled together in the current sub-project,
// leaving external libraries untouched so their future bug-fixes keep
// flowing through. Applied to `core/` only; `circeIntegration/`
// deliberately omits it (crosses into circe, where we must NOT bake
// internals). `generics/` also omits it — inlining across macro
// expansions is both unnecessary and risks leaking compiler-internals
// into user bytecode.
lazy val scala3CoreSettings = scala3LibrarySettings ++ Seq(
scalacOptions += "-opt-inline:<sources>"
)

// Macro-bearing modules additionally enable `-Xcheck-macros` so
// malformed quotes surface at macro-compile time rather than as NPEs
// in downstream builds.
Expand Down Expand Up @@ -597,7 +602,7 @@ lazy val root: Project = project
lazy val core: Project = project
.in(file("core"))
.settings(commonSettings *)
.settings(scala3CoreSettings *)
.settings(scala3LibrarySettings *)
.settings(
name := "cats-eo",
libraryDependencies += cats,
Expand Down Expand Up @@ -1150,6 +1155,76 @@ lazy val benchmarks: Project = project
libraryDependencies += jsoniterMacros,
)

// -------------------------------------------------------------------
// Demo: a UPLC (Cardano Plutus Core) → JVM bytecode compiler built from eo isos,
// CborScript ⇀ TypedProgram ≅ JvmClass ⇀ JvmClassScript — the script in, a class
// file out, and the class hands its script back. Correctness
// is differential: every program runs through the generated class AND the
// Scalus CEK machine, and the two results must agree. Unpublished and OUT of
// the root aggregate (see the CI step above); run with `sbt demoUplcJvm/test`.
// -------------------------------------------------------------------
lazy val ScalusVersion = "1.3.0"
lazy val scalus = "org.scalus" %% "scalus" % ScalusVersion
// JvmClass is ASM's own in-memory class (asm-tree); asm-commons' InstructionAdapter writes it.
lazy val AsmVersion = "9.11"
lazy val asmCommons = "org.ow2.asm" % "asm-commons" % AsmVersion
lazy val asmUtil = "org.ow2.asm" % "asm-util" % AsmVersion

lazy val demoUplcJvm: Project = project
.in(file("demos/uplc-jvm"))
.dependsOn(
LocalProject("core"),
// IsoTests: the suite checks the optics' round-trip laws with the library's own rule set.
LocalProject("laws") % Test,
LocalProject("demoUplcJvmValidators") % Test,
)
.settings(commonSettings *)
.settings(
name := "cats-eo-demo-uplc-jvm",
publish / skip := true,
libraryDependencies += scalus,
libraryDependencies += asmCommons,
// Textifier: the walkthrough prints a JvmClass as bytecode.
libraryDependencies += asmUtil % Test,
libraryDependencies += discipline % Test,
// Unpublished, so the optimizer may inline from anything on the classpath (Scalus, ASM,
// cats, eo) — except Scala's own library: inlining it into the runtime makes `Rt.force` too
// big for the JIT to inline, and the short validations ran 12-27% slower (measured with the
// demo's JMH benches; everything else inlined measures neutral to ~5% faster).
scalacOptions ++= Seq("-opt", "-opt-inline:**,!scala.**"),
)

// On-chain validators written in Scala and compiled to UPLC by the Scalus
// compiler plugin — the second half of the demo's oracle corpus. Isolated in
// its own module so the plugin (and the Scalus-flavoured Scala it accepts)
// never touches the compiler's own sources.
lazy val demoUplcJvmValidators: Project = project
.in(file("demos/uplc-jvm-validators"))
.settings(commonSettings *)
.settings(
name := "cats-eo-demo-uplc-jvm-validators",
publish / skip := true,
libraryDependencies += scalus,
addCompilerPlugin("org.scalus" % "scalus-plugin" % ScalusVersion cross CrossVersion.full),
)

// JMH benchmarks for the demo: the CEK machine against the generated class, and what the
// translation itself costs. Kept apart from `benchmarks/` so the nightly sweep and
// BENCHMARKS.md stay about the library; results are reported on the demo's docs page.
// Run with `sbt "demoUplcJvmBench/Jmh/run -prof gc"`.
lazy val demoUplcJvmBench: Project = project
.in(file("demos/uplc-jvm-bench"))
.enablePlugins(JmhPlugin)
.dependsOn(
LocalProject("demoUplcJvm"),
LocalProject("demoUplcJvmValidators"),
)
.settings(commonSettings *)
.settings(
name := "cats-eo-demo-uplc-jvm-bench",
publish / skip := true,
)

// Single source of truth for the JMH invocation (the config that the per-class
// annotations pin — see JmhDefaults' Phase-4 note for why that preamble can't be
// DRY'd). Append a filter: `sbt "bench .*OrderAvroBench.*"`.
Expand Down
Loading