Skip to content

Count generic method cardinality, and report what blocks the rest - #10

Merged
kryptt merged 45 commits into
mainfrom
feat/sbt-plugin-skeleton
Oct 5, 2026
Merged

kryptt merged 45 commits into
mainfrom
feat/sbt-plugin-skeleton

Conversation

@kryptt

@kryptt kryptt commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

The report plan (laid out in the Delta thread): the calculator measures a real codebase, counts
what a generic method can actually be, and names what blocks the rest.

What

  • core: the calculator, moved to core/ and packaged as cardinality — empty-package members
    cannot be referenced from packaged code, which the plugin needs to do.
  • Definitions (M1): Counter.definitions walks a source's definitions — classes, enums,
    modules, type aliases, opaque types, nested ones included — with the cardinality each holds, the
    names the calculator could not bound, and the line it sits on.
  • Method and constructor cardinality: Inhabitation counts the canonical pure, total,
    parametric implementations a signature admits with everything in scope — choose[A](x: A, y: A): A
    is 2, Pair's constructor is 4, use[A](x: A, step: A => A): A is ω, a cycle with no starting
    inhabitant is 0. Exact BigInt arithmetic, provenance-carrying bindings, a least-productive fixed
    point and cycle detection. The stored-value estimate over definitions stays a separate section:
    Pair[Int] is still 2^64.
  • Scope (MethodAnalysis): two passes — index declarations, owners and binders, then resolve
    each signature against them. Captures include enclosing fields, enclosing parameters, local
    values, callable producers and product projections; binder identity is per declaration. Nothing
    resolves outside the supplied sources: no dependency resolution.
  • Honesty: unsupported scope or type analysis is ? with its reason, and the section header
    sums the reasons into a triage list. ω/τ are never fallbacks. A source the report cannot read
    is reported, not thrown.
  • plugin: cardinalityReport writes and logs the report for the project's Compile sources
    (cardinalityReportFile), and cardinalityReportOf <path>... reports on any Scala sources — a
    directory, a file, or a library's published sources jar. Both fail after logging when a source
    could not be read, and both are covered by scripted tests, the library one by packaging a fixture
    sources jar.
  • Gates follow the layout: CPD scans both modules; scripted is a CI gate; the coverage floor is
    scoped to core and stays at 80.

The first real run: eo-core 0.16.0 (dev.constructive %% cats-eo), 480 signatures

Generic method / constructor implementation cardinalities
  480 signatures: 41 finite · 3 countably infinite · 436 unresolved
  249 unresolved on: unresolved type          184 on: given environment
  148 on: abstract or method-valued type      174 on: higher-kinded parameter F[_,_]/F[_]
  138 on: unnormalized body or capture        114 on: import or qualified member

4  SplitCombineLens.<init>  SplitCombineLens[S,T,A,B,XA](read, split, combine)  Lens.scala:199  [constructor]
2  SplitCombineLens.get     get(s: S): A                    Lens.scala:208  [method]  captures: combine, read, split
1  Accessor.tupleAccessor.get  get[X, A](fa: (X, A)): A     Accessor.scala:18  [method]
1  ReverseAccessor.eitherRevAccessor.reverseGet  (a: A): Either[X, A]          Accessor.scala:17
ω  SimpleLens.place  place(a: A): S => S                     Lens.scala:265  [method]
0  CanGet.get  get(s: S): A                                  CanGet.scala:16  [declaration]

The 4 is the Pair[A](x, y) rule on real code; the 0s are declarations whose signature cannot be
implemented from its scope alone (the free-theorem reading); ω is a captured endomorphism being
reapplied.

Next milestones, by what the triage list says

  1. Givens and abstract declarations as caller-provided values (184): a using ev: Unknown[A] or an
    abstract given seed: A is a slot a caller fills, and should bind like a parameter — while a
    foreign given cannot hold this method's binders and should not block anything.
  2. Sealed hierarchies as sums (148): eo's traits are case sets the source set can enumerate.
  3. Higher-kinded parameters (174): F[_]/F[_, _].
  4. Body normalization (138) and imports/qualified members (114).

Verification

  • core/testFull: 153 examples green — the solver's semantics, the scope and binder rules, the
    report walk and rendering, unreadable and unparsable input.
  • plugin/scripted: both scenarios green.
  • scalafmtAll; scalafmtSbt; scalafixAll --check clean; coverageAll green at 85.0% stmt / 78.3%
    branch (floor 80); CPD clean; CodeScene reports no new findings after splitting the passes the
    gate read as complex.
  • Mutation testing (report-only) is at 58.2% of all mutants — the new analysis code is where the
    survivors live, and it is the next test-investment target.

Validated against the inhabitant-counting reference

docs/counting-type-inhabitants.md records Alex Knvl's Counting type inhabitants as the
specification this solver follows, and ReferenceInhabitantsSpec runs 27 of its worked examples:
products, sums and exponentials (Either Bool (Maybe Bool) ~ 5, Maybe Bool -> Bool ~ 8,
Bool -> Maybe Bool ~ 9, Nothing -> Bool ~ 1, Bool -> Nothing ~ 0), rank-1 polymorphism
(∀a. a -> a ~ 1, ∀a. (a,a) -> a ~ 2, ∀a. (a,a) -> (a,a) ~ 4, composition, filter-style
signatures), recursive types (∀a. (a -> a) -> a -> a ~ ℵ₀, ∀a. (a -> a) -> a ~ 0) and the
negation family ((x -> 0) -> 0 ~ 1 iff x > 0). Two solver corrections came out of it: absurd
elimination for inhabited empty types, and keeping an unproductive cycle at 0.

An audit of the frontend against the same reference found and fixed four scope bugs: the CodeScene
split had silently dropped trait/enum type registration (false finite counts — an enum is now the
sum of its cases); a declared member is a slot the caller fills (def seed: A supplies an A,
def step(a: A): A supplies a re-appliable callable → ω), inherited declarations resolved through
the subclass's type arguments; a concrete member neither binds nor blocks, because a total
parametric body can only compute what the environment already holds; and import/export shadowing of
a modelled builtin is the one way something outside the lexical chain changes an answer here.

eo-core, as it reads now

480 signatures: 10 finite · 3 countably infinite · 467 unresolved
  343 unresolved on: unresolved type          237 on: abstract or method-valued type
  174 on: higher-kinded parameter              57 on: qualified member environment
   47 on: unsupported type                     16 on: mutable capture

The finite count moved down from 41 because eo's traits now genuinely contribute their declared
capabilities (F[_], modifyF, foldMap[M]), which this fragment cannot express — the sound
reading of those signatures. Next, in order: primitive shapes (Int, Long, Char as finite
inhabitants sets, the article's Int ~ 2^32), sealed hierarchies as sums (237 signatures), and
refining which inherited capability can produce the target's shape.

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

CI · 512fc87

Gate Result
Formatting (scalafmt) ✅ success
Semantic rules (scalafix) ✅ success
Tests + coverage (scoverage) ✅ 90.15% statements (floor 80%), 83.66% branches
Plugin tests (scripted) ✅ success
Duplicate code (CPD) ✅ no duplicates over 100 tokens
Documentation site (Laika) ✅ renders under target/site

Workflow run

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Quality · 512fc87

Report Result
Mutation testing (stryker4s, report only) ⚠️ 67.74% of all mutants, 71.86% of covered code (756 killed, 296 survived, 64 not covered; low 60%, high 80%)
Code Health (CodeScene) ✅ no new findings

Workflow run · mutation report in the stryker4s-reports artifact

@kryptt kryptt changed the title Restructure into core + sbt-cardinality plugin modules (M0 walking skeleton) Count generic method cardinality, and report what blocks the rest Sep 23, 2026
…eleton)

The report work (per-file, per-definition cardinality of an sbt project)
needs an sbt plugin. This is the M0 walking skeleton agreed in the plan:

- core: the existing calculator, moved to core/ and packaged as
  'cardinality' (empty-package members cannot be referenced from packaged
  code, which the plugin now needs to do).
- plugin: sbt-cardinality, an auto-triggered plugin whose cardinalityReport
  task runs Counter over every Compile source and logs one line per file
  plus a total. Def.uncached: a report must never be served stale from
  sbt 2's task cache. Tested end-to-end with scripted.
- Everything builds with Scala 3.8.4, the version sbt 2.0.x runs plugins
  on: Scala 3 binary compatibility is backward only, so 3.9-built
  artifacts could not load inside sbt, while 3.9 users can consume 3.8.4
  artifacts unchanged.
- Gates follow the layout: CPD scans both modules' main sources, the
  scripted suite is a new CI gate, the coverage floor is scoped to core
  (scripted runs outside scoverage instrumentation) and ratchets 65 -> 80
  to sit just below the measured ~84% baseline, and mutation testing is
  scoped to core/stryker.

Skeleton limits, to be grown next: raw Size toString output (M2 renders),
flat per-file lines (M1 builds the per-definition tree), and .get on parse
results (M1/M3 report every parse error with positions before failing).
…bounded

M1 and M2 of the report plan. The calculator now walks the definitions a source
introduces — classes, enums, modules, type aliases, opaque types, nested ones
included — and returns each with the cardinality it holds, the line it is
defined on, and the type names that stopped it from being bounded.

- `Counter.definitions` records the walk `Counter.source` already performed and
  descends into the bodies of the definitions it finds, so an ADT whose cases
  live inside its trait or companion — the common Scala 3 shape, and every ADT
  in `eo` — is counted and listed. Nested values still add nothing: they are
  state derived from the fields, which already count it.
- `Counter.source` sums what the walk contributes, so a nested definition is
  now part of a source's cardinality: `sealed trait Light { case object Red … }`
  was 0 before and is 3.
- An unbounded size carries its reason. The scope records the type names the
  calculator could not resolve — `String`, a type parameter, a match type, an
  unmodelled constructor such as `Fst`, a collection whose length is unbounded —
  so a report says why instead of printing ω.
- `Size.render` (M2): exact up to 1024, `2^n` above, `ω`/`τ` for the two
  infinities, Float and Double marked lossy, since their count of bit patterns
  is an upper bound rather than a cardinality.
- `Report.of` reads a file, a directory or a `-sources.jar` — the shape a
  published library ships — and renders what it found: the summary separates
  the definitions that are unbounded only because of their own type parameters,
  then one line per definition, ordered by cardinality, names the types it could
  not bound. A source it cannot read or parse is reported rather than thrown.

`defnIn` stays the one rule for what a definition contributes; the walk adds the
rows and the descent, so the two cannot drift apart.

core: 89.3% statements / 82.0% branches, up from the 84/79 baseline.
- `cardinalityReport` logs the report of the project's `Compile` sources and
  writes it to `target/cardinality/report.txt` (`cardinalityReportFile`), so a
  build can keep it, diff it, or post it as an artifact.
- `cardinalityReportOf <path>...` runs the same report over sources the build
  does not compile itself — a directory, a single file, or the `-sources.jar` a
  published library ships. That is how a dependency's types get measured from
  outside its build; the first real run, over `eo-core` 0.16.0, is in the
  README, along with what it says.
- Both tasks write the report, log it, and only then fail when a source could
  not be read or parsed: a report over sources the calculator could not read is
  incomplete, and failing quietly would present what it did find as the whole
  story. (M3's report-then-fail, one milestone early.)
- Scripted: the walking skeleton's scenario now mirrors a golden report, and a
  new scenario packages a fixture library's sources jar and reports on it, so
  the library path is covered end to end without network access.
The mutation report on this branch's first push (report-only, 69.7% of all
mutants) pointed at assertions the new code was missing. Each of these now
fails when the line it guards is mutated:

- a source's total counts a top-level value, not just its definitions
  (`Counter.source`'s `top = true`),
- a row that is bounded says nothing about being unbounded (`Report.render`),
- a sources jar keeps the entries that are not Scala out of the report,
- a signature carries every parameter it declares, and the summary counts a
  definition whose parameters are only partly unresolved — so the count is
  `exists` over the parameters, not `forall`.
A signature's cardinality is the number of canonical pure, total, parametric
implementations it admits with everything in scope — not the count of stored
values an instantiated data type holds. `choose[A](x: A, y: A): A` has two (x or
y); the constructor of `Pair[A](x: A, y: A)` has four ways to build its product,
while `Pair[Int]` still holds 2^64 values.

`Inhabitation` solves that count over shapes — free atoms, products, sums, arrows
— with exact BigInt arithmetic:

- a productive fixed point first, then cycle detection over the live rules, so a
  re-appliable producer (`x`, `step: A => A`) is ω while a cycle with no starting
  inhabitant is 0, not ω;
- provenance distinguishes values: two bindings of the same shape are one
  construction only when they name the same source;
- finite sums in scope are split exhaustively, so a `Boolean` argument branches;
- what the fragment cannot decide — higher-order application, elimination of an
  opaque sum-producing callable, an arithmetic or state budget — is `Unresolved`
  with the reason, never a finite guess or an invented infinity.
Two passes over the supplied sources: the first indexes declarations, lexical
owners and their binders; the second resolves each signature against them and
counts what a total, parametric implementation of it could construct.

- Captures are real: enclosing constructor fields and values, enclosing method
  parameters, local values, and named producers; a product input is projected
  into its fields. Value aliases fold onto the value they name (including
  forward chains), and an alias whose source cannot be resolved fails closed
  rather than inventing an opaque value that would make an identity function
  look like a productive cycle.
- Binder identity is per declaration: a shadowed `A` is not an outer `A`, a
  method body's type alias does not leak into its signature, and a value that a
  parameter shadows stays counted through its receiver (`this.x`).
- A total parametric method cannot invent a value it never receives, so
  inherited or declared members and unrelated imports do not block a count; a
  signature that mentions a concrete atom (`Int`, `Boolean`) with an open
  environment does, because there an outside producer could change it.
- Case-class products are only products when the whole product is public: one
  parameter list, no private or protected fields, no extra members, not
  abstract.
- The report leads with the method counts, sums the reasons into a triage list
  (`N unresolved on: …`) and keeps the stored-value estimate below it as its own
  section, with `?` wherever that estimate is unresolved — a guess is not a
  proof of infinity, and an opaque representation is not a singleton.

Over eo-core 0.16.0: 480 signatures, 41 finite, 3 countably infinite, 436
unresolved with named reasons. Coverage 85.2% statements / 78.6% branches.
CodeScene flagged the new module for nested complexity, and it was right: `goal`
built its rules in one deeply nested match, and `run` walked, decided and counted
in one body. Both are now a small dispatcher over named passes — a rule per shape
(`split`, `constructors`, `producers`, `application`), a least-productive fixed
point, and a `Live` view whose reasons, cycle detection and exact arithmetic are
separate walks.

`return` is gone too: the repository bans it via scalafix.
The quality gate measured `measurement` at cyclomatic complexity 32 and
`namedType` at 24, against a threshold of 9. Both are now dispatchers over named
steps: a `Measurement` that holds the scope and reports one part at a time
(scanning a frame, folding aliases, flagging what is reachable, resolving the
result, counting), and a `namedType` that separates the builtins it spells
itself from a source type's representation. No diagnostic changed: the analysis
over 66 input batteries — every builtin spelling, shadowing, captures,
qualification, cross-file and forward aliases, givens, imports, extensions,
enums, recursion — prints byte-identical output before and after.
The gate's next two findings: `index` was a 134-line dispatcher (threshold 70)
and `parameterized` took seven arguments. `index` now names one method per
declaration kind — package, package object, class, template, object, given,
extension, method — with the class's constructor target and the template's body
split out, and `parameterized` carries one `Applied` record instead of seven
arguments.
The gate's last finding: `indexTemplate` took eight arguments. The body a
definition opens — its node, name, statements, binders and constructor inputs —
travels as a `Template`, built by one method per declaration kind.
`docs/counting-type-inhabitants.md` records Alex Knvl's *Counting type
inhabitants* as the reference this solver follows, and
`ReferenceInhabitantsSpec` runs its worked examples — 27 of them, all green:
`Either Bool (Maybe Bool) ~ 5`, `Maybe Bool -> Bool ~ 8`, `Bool -> Maybe Bool ~ 9`,
`∀a. a -> a ~ 1`, `∀a. (a,a) -> a ~ 2`, `∀a. (a,a) -> (a,a) ~ 4`, composition and
`filter`-style signatures, `∀a. (a -> a) -> a -> a ~ ℵ₀` and the unseeded `~ 0`,
plus the negation family (`x -> 0` has at most one inhabitant, `(x -> 0) -> 0 ~ 1`
iff `x > 0`).

Two solver fixes come out of it:

- Absurd elimination. An inhabited empty type proves the context impossible, and
  every expression under that assumption is observationally equal — the unique map
  out of the empty type. `∀a. (a -> 0) -> a -> 0 ~ 1` and two empty-result
  functions are indistinguishable, so an empty target is now a rule of its own and
  an empty-result callable is a negation rather than an opaque sum.
- `∀a. (a -> a) -> a ~ 0`: an unproductive cycle stays 0 rather than an unknown.

The frontend audit that came with the reference found four scope bugs, all fixed
here:

- The indexing split dropped type registration for traits and enums: an enum's
  cases resolved to Unit and a trait's members to "abstract", which are *false
  finite* counts. Both register again, and an enum is the sum of its cases.
- A declared member is a slot the caller fills, so `def seed: A` supplies an A and
  `def step(a: A): A` supplies a callable that can be reapplied (ω, not 1).
  Inherited declarations travel too, resolved through the subclass's type
  arguments so `extends Base[A]` reads Base's A as this A.
- A *concrete* member is a specific inhabitant of its type, and a total parametric
  body can only draw values from the environment it already has: it neither adds a
  choice nor blocks one, so it is no longer a diagnostic.
- An import or an export can shadow a name the model reads as a builtin, and that
  is the one way a name outside the lexical chain changes an answer here; a module
  nested inside the target's own scope shares its binders and is part of the scope.
The gate reads a file over 600 lines of code and a module over 75 functions as
unhealthy, and `scanParents` nested six deep. The measurement — the scope one
signature sees, its aliases, its captures, its count — moves to
`Measurement.scala` behind a package-private `Resolver` trait that the index
implements, so no resolution logic is duplicated. `scanParents` flattens into
`scanParent`/`scanDeclared`/`inherited`.

No behavior change: the analysis over the 53 real eo-core sources is
byte-identical before and after (480 signatures, same sha256), as are two
batteries of the analyzer's own sources.
The same free theorem that keeps a concrete method out of the environment applies
to a value: a total parametric body can only compute what the scope already holds,
so an unreadable body — `val step: A => A = id`, `val (a, b) = pair` — names one of
those bindings rather than adding a choice. Blocking the count on it was the
larger error: the reviewer's known-identity case now reads 1 (only `seed`) where
it read `?`, and 85 eo-core signatures stop being blocked by a reason that was
never a reason.

Aliases are still followed, because an alias *names* a binding and so raises the
precision of the count.
A declared member is a slot the caller fills, but the signature under measurement
is not one of its own slots. Binding it let `CanGet.get(s: S): A` claim an
implementation by calling itself: it read 1 where the honest answer is 0 (nothing
in that scope supplies an A), and four more eo declarations read 1 for the same
reason. `CanGetOption.getOption(s: S): Option[A]` now reads 1 -- only `None` is
constructible -- and `CanPlace.place(b: B): T => T` reads 1, the identity.

eo-core: 15 finite, 2 countably infinite, 463 unresolved.
Russell 1907 (PLMS s2-4, pp. 29-53) is the foundational reference for the
counting contract's first discipline: a definition does not automatically
license a collection, and cardinality is not order type. Two documents now
carry that connection into something a review can check.

- docs/research/code-cardinality-foundations.md: the evidence register. For
  each decision in the counting contract (scope before counting, mathematical
  inhabitants vs expressible implementations, parametricity, productivity,
  cardinality vs order, labeled approximation, `?` as a first-class answer,
  budget honesty) it names the sources, how far each was verified
  (executable / inspected / bibliography-verified / metadata-verified /
  classical), and the boundary past which each must not be cited. Reynolds
  1984, Pitts 1987, Wadler 1989, Smyth & Plotkin 1982 and the definability
  literature (Luna & Taylor 2010, Fan 2020) are verified at the stated levels
  from Crossref, publisher pages or fetched abstracts.
- docs/research/russell-citation-inventory.md + russell-citations.json: every
  citing work the public indexes report for Russell 1907 — OpenAlex 301
  (all cursor pages), Semantic Scholar 212 (single page), Crossref count 70;
  376 after a DOI-or-title+year union. Database-reported is kept distinct
  from verified: one bibliography-verified citer (Fan 2020), abstract-inspected
  (Luna & Taylor 2010), the rest title-screened with per-record source flags.

The plan's reference section links both and directs reviews to answer from
the register, not from memory.
The report's walk was written against the Counter the branch forked from,
where a name was read as its statement passed and a forward reference was
`omega`. The algebra under it has changed since: recursive types are solved as
least fixed points, lazy cycles as greatest ones, sizes are epsilon0-tier
polynomials, and a member signature is an additive reading of its own. A
report that kept the old walk would print the old numbers.

The walk now solves each body once — `solve` over the body's equations, the
same call the source total makes — and hands every definition the value a
reference to it has, so a row and its reference always agree: `Succ` in a
Peano `Nat` reads `omega` (not a question mark), a lazy `Timeline` reads
`omega`, a function space over a countable type reads `epsilon0`, and what the
report cannot bound is `?` with the unresolved names that stopped it. Since
`omega` and `epsilon0` are answers now, they no longer mark a row as
unresolved by themselves; only a name outside the calculator's vocabulary (or
an opaque representation) does. A capacity is a bound, not a count, so the
report says so: "finite capacities are upper bounds".

The first run over real code found the name rule wanting: eo-core's
`object Modify` reported the `epsilon0` of `class Modify`, because a companion
module and its type are both called `Modify` and the scope kept one value per
name. A type now claims the name it shares with its module — a type reference
means the type — while the module is worth one value either way, as a row and
as a contribution. (`CardinalitySpec`, `ReportSpec`.)

Verification: `core/test` all 9 suites green (389 examples, 18 pending);
`plugin/scripted` green with the shipped expectations; the run over eo-core
0.16.0 (53 sources, 134 definitions) reads 40 unresolved, 2 with more than one
value, 53 with one value, 5 with no values, 34 abstract — the two `2^32` array
builders are its only non-module finite sizes, and the generic classes are
`?` because their own type parameters are what stop the count.

The report fixture now carries the features: `sealed trait Nat`/`Zero`/`Succ`,
a lazy `Timeline` and a `Reducer` whose function space is epsilon0.
The branch restructured the sources into `core/` and `plugin/`, and main's
docs landed after the fork, so several references only make sense on one side
of the merge:

- README and `docs/` linked `src/main/...` and `src/test/...` paths that moved
  under `core/`; the GitHub blob links in `docs/type-arithmetic.md` and
  `docs/index.md` 404'd.
- Laika rejects the docs' internal links: an anchor id maps `.` and a leading
  digit to `_` (`#_2-agreed-counting-contract`), and a quoted `[polymorphic]`
  read as a reference link with no target. The `ci.yml` documentation gate
  fails on both, so they are escaped and re-anchored.
- CodeScene's strict gate matched `src/**`, which the restructure emptied;
  `**/src/**` covers both modules again.
- The README's report snapshot and eo-core baseline were the pivot's format
  (`unbounded by:`). Both now read the merged report: rows of
  `number, name, kind, location`, `?` for unresolved names, the summary lines
  the report actually prints, and the numbers of a fresh eo-core 0.16.0 run.
The task was a common setting, so aggregation ran it for core and plugin as
well and wrote the same site three times per invocation. It belongs to the
repository, not to a module.
The unused overload was dead weight next to the solver's one real merge (a
round's evaluated names, which carries no notes of its own).
The plan still spelled the tier above countable as tau, which #16 replaced
with the analysis' epsilon0 tier.
The plan's first milestone asked for the full report to survive, not just the
table of totals: a run is only comparable to a later one when the artifact,
the analyzer revision, the input's checksum, the tool versions and the analysis
limits travel together. `docs/baselines/eo-core-0.16.0.txt` carries all of
that in its header and the report underneath it, and the plan and README point
at it instead of at numbers with no artifact behind them.

The run is the rebased plugin over `cats-eo_3-0.16.0-sources.jar`, whose
SHA-256 matches the one the plan already recorded. Its summary: 53 sources,
134 definitions — 40 unresolved, 2 with more than one value (the `2^32` array
builders), 53 with one value, 5 with no values, 34 abstract — and 480
signatures: 15 finite, 2 countably infinite, 463 unresolved.

The site now links `counting-type-inhabitants.md` from the landing page and
the navigation order, and Laika copies the baseline next to the plan that
cites it.
@kryptt
kryptt force-pushed the feat/sbt-plugin-skeleton branch from 7d9ec73 to cf5ee45 Compare September 30, 2026 21:53
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

🚀 Cloudflare Pages preview for feat/sbt-plugin-skeleton is live:

https://3683770d.scala-cardinality-site.pages.dev

Built from 512fc878c755833110783e4be43a3199ca248831 · updated on every push.

…lacks

The baseline has 134 definition rows and 40 of them are `?`. This adds the other
half of that baseline: a ledger that says, for each of the 40, what stopped it,
what would move it, and the order the work is worth doing in
(`docs/baselines/eo-core-0.16.0-unresolved.md`).

What the ledger found:

- The dominant gap is not a counting rule but an answer class. eo's optics are
  generic and built from functions over their parameters (`Getter[S, A](read: S
  => A)`), so a definition's stored-value estimate is a function of its
  instantiation. 29 of the 40 rows are this kind, and the report says
  `unresolved: S, A` where it should say that no number exists for a template.
- One gap is simply wrong answers: an applied user-defined type is never
  resolved. `case class Use(p: Pair[Boolean])` reports `ω`, `unresolved: Pair`,
  where 4 is the answer — and the same for a match type applied to a concrete
  tuple (`Fst[(Boolean, Boolean)]`).
- The estimate's scope is one source file, so `Box` in `A.scala` and
  `Use(b: Box)` in `B.scala` reads `ω` even though the report holds both
  sources; the method side of the same report already indexes all of them.
- Three rows are `?` by design (opaque representations) and five are honest
  unboundedness (unsealed capabilities a caller implements); neither is a gap.

The three capabilities that fix wrong answers rather than missing reasons are
pinned as pending targets in `ReportSpec`, so specs2 reports them as pending
today and fails if the marker ever goes stale once a rule lands. The plan's V1.3
milestone links the ledger and moves from "candidates identified" to "the
definition half is started".
`case class Use(p: Pair[Boolean])` read `ω`, `unresolved: Pair`, where 4 is the
answer: an applied user-defined type was never resolved, only the builtin
constructors (`Option`, `Either`, `Set`, the collections) were. That is wrong
for any project with parameterised types, and it is the machinery every other
item on the ledger's list needs.

A scope now carries the definitions themselves — each with its type parameters
and its equation — alongside the solved values, and `C[args]` reads the
definition's equation with its parameters bound to what the arguments are
worth (`Pair[Boolean]` is `Pair`'s `a: A, b: A` with `A := 2`). An argument that
is itself unresolved keeps its own reason, so `BijectionIso[S, S, A, A]` reads
as `S` and `A` rather than as a name the file defines.

Two recursive readings stay careful. An instantiation that is exactly the
definition's own parameters — `Node[A]` inside `Node`'s equation — borrows the
fixed point the solver already has for the name, so a parameterised recursion
keeps its μ/ν reading. Any other instantiation entered while its own name is
being substituted is a cycle the name-keyed solver has no fixed point for, and
keeps the old fallback: ω with the name as the reason.

On eo-core the three concrete applications now read right and three rows name
their own parameters instead of an internal type (`Iso[S, A]` said
`unresolved: BijectionIso`); `ModifyF` still names `Fst`/`Snd`, which live in
another file — the next step. Suite: 391 examples, 2 pending.
After the substitution step the estimate still read one file at a time, so a
type a sibling file defines counted as an unknown name: `Box` in `A.scala` and
`Use(b: Box)` in `B.scala` read `ω`, `unresolved: Box`, where 2 is the answer,
even though `Report` hands the whole source set to the same run.

`Counter.Library` is now that set: every source's top-level definitions grouped
by the package they are read in, each package solved as one system, so a
definition can reference a sibling file's names — and a sealed hierarchy that
spans files sums as a whole, which the name-keyed solver could not do before.
`Report.of` builds the library once and reads every source against it;
`Counter.source` and `Counter.definitions` take it as an optional argument, and
`Library.empty` keeps the single-file reading that the specs use.

What resolves, and what stays unresolved, is a rule with a reason:

- the file's own definitions and solved values come first, as before;
- then its own package's members, which need no import;
- then a name that exactly one package across the source set defines, which is
  the shape an import of a single name has;
- a name a body imports directly is left to the import — the calculator does
  not follow imports, so a library answer for it would be a guess;
- a name two packages define stays unresolved rather than guessed.

The plugin fixture now splits the Peano family across two files, and its
expected report reads `example.Cursor` (in one file) as `ω` through `Nat` (in
the other) — the end-to-end proof that a project's sources are read as one set.
Suite: 398 examples, 1 pending (the match-type target).
kryptt added 20 commits October 1, 2026 20:23
A row whose only reasons are the type parameters it declares has no number to
show — every instantiation has its own, and the caller supplies it — but the
report read `unresolved: S, A` exactly as it read a name the sources do not
define. That is the wrong class of answer: it sends a reader looking for a
missing rule where there is none.

`Definition` now carries reasons by kind — `Parameter`, `HigherKinded`, `Open`,
`Unknown`, `Syntax` — and the report reads them: a row that depends on its
instantiation says so (`depends on its instantiation (A, S, T, B)`), a row an
open abstraction unbounds will say that, and any other row lists what it could
not read as before. The summary counts the classes apart, so `?` no longer
means one thing.

On eo-core the definition summary moves from 40 unresolved to:
18 unresolved · 22 instantiation-dependent · 2 with more than one value ·
53 with one value · 5 with no values · 34 abstract. The 22 are the optics
templates (`BijectionIso[S, T, A, B]`, `GetReplaceLens[S, T, A, B]`, the `X`
aliases); the remaining 18 mix a parameter with a match type, a higher-kinded
parameter, an open trait, `Any`/`Null`, a refinement or a name outside the
sources — the next steps on the list.

The plugin fixtures show both: `example.Holder[A]` reads `depends on its
instantiation (A)` while `example.Reducer` keeps `unresolved: String`.
Suite: 398 examples, 1 pending.
A reference to an unsealed trait has no bound to compute: any subtype anywhere
can add values, so `CanModify[S, A] = CanModifyP[S, S, A, A]` could never be a
number, whatever the calculator learns. It was reported as `unresolved:
CanModifyP`, which sends a reader looking for a rule that would not help.

The bodies now record the abstractions they leave open — unsealed traits and
abstract classes, which the library knows per package too — and a name that
resolves to one is the `Open` kind. The report says `open to implementations
(CanModifyP)` and counts it as `unbounded by an open abstraction`, apart from
what the calculator could not read. A sealed parent is not open: its children
are the sum, and the row for a reference through it is a number.

On eo-core the definition summary moves to: 14 unresolved · 22
instantiation-dependent · 4 unbounded by an open abstraction · 2 with more than
one value · 53 with one value · 5 with no values · 34 abstract — the four are
the capability aliases (`CanModify`, `CanModifyA`, `CanModifyF`, `CanPut`).
Suite: 400 examples, 1 pending.
`type Fst[T] = T match { case (f, s) => f }` applied to a tuple was `ω`,
`unresolved: Fst`: the applied alias went through its body, and a match type is
not a size the algebra can read. `Fst[(Boolean, Boolean)]` is `Boolean` —
2 — and for eo-style code, `Fst[A]` over a free parameter stays inert, which
is the honest reading.

An alias whose body is a match type now carries that body with its definition,
and applying it reduces on the argument's *syntax*, which no size can carry:
the case pattern is matched against the scrutinee (the definition's parameters
already replaced by the arguments), the first case that matches gives its body
with the pattern's binders replaced by the parts of the scrutinee they stood
for, and the result is read as a type. A pattern variable is spelled the way
Scala 3 spells one — a lowercase name — so `case Int => Boolean` matches by
spelling and `case x => x` binds whatever it is given. A tuple pattern matches
a tuple scrutinee of the same arity, an unread pattern binds nothing, and a
match type no case matches stays unreduced: the report names it rather than
guessing, which is what eo's `Fst[A]`/`Snd[A]` over free parameters keep
reading.

Suite: 402 examples, 18 pending (`ArticleCardinalitySpec`'s targets). On
eo-core the definition summary is 15 unresolved · 21 instantiation-dependent ·
4 unbounded by an open abstraction · 2 with more than one value · 53 with one
value · 5 with no values · 34 abstract; `ModifyF[A, B]` moved from pure
instantiation-dependence to `unresolved: A, B, a match type`, which is what it
is — both readings are waiting on the caller.
`F[_]` and `F[_, _]` were `unresolved: F`: the same reason a misspelled class
gets, for a parameter that is the caller's by construction. eo's three
`Forget`-carrying definitions read that way, and the method side already
phrases the same gap as `bounded or higher-kinded parameter F[_]`, so the two
halves of a report now speak the same language.

A binder carries how many parameters it takes, and an applied `F[A]` over one
reasons `HigherKinded(name, arity)`, which renders as `F[_]` or `F[_, _]`. A
row whose reasons are all parameters — plain or higher-kinded — stays in the
instantiation-dependent class: the number exists, the caller has it.
`Null` and `Any` were unmodelled names, so `PSVec.Slice[+B](arr: Array[Any],
offset: Int, length: Int)` — a real array-backed vector — read `unresolved:
Any`, and `AssocSndZ`'s `Array[Int] | Null` did too. Both are the lattice's
ends, and the calculator can say what they are: `Null` has the one value
`null`, and `Any` is the top, which nothing the analysis can place is above —
so it sits at the ε₀ tier, the analysis' top. Other top-ish names (`AnyRef`,
`Matchable`) stay unmodelled rather than guessed.

On eo-core two rows read as numbers now: `Slice` is `ω` (its fields multiply a
countable array space) and `AssocSndZ` depends on `Xo` alone. The definition
summary reads: 13 unresolved · 22 instantiation-dependent · 4 unbounded by an
open abstraction · 3 with more than one value · 53 with one value · 5 with no
values · 34 abstract. Suite: 404 examples.
A match type that no case matches is inert over whatever the argument is, so
the argument's own reason belongs on the row: eo's `Affine.Miss[A]` reads
`unresolved: A, a match type` rather than losing the parameter that decides
whether the case would ever fire.
The artifacts still described the state before the counting work: the baseline
said 40 unresolved rows, the ledger listed substitution, cross-source scope and
match-type reduction as missing, and the README and plan carried the old
counts.

The baseline is regenerated from the run at this revision, and the ledger now
reads as a ledger of what moved and what is left: of the first baseline's 40
`?` rows, 31 moved — one became a number (`PSVec.Slice` is `ω`), 22 read
`depends on its instantiation (…)`, 4 read `open to implementations (…)`, and
the rest name a match type or a higher-kinded parameter instead of an internal
type name. Six gaps remain: type lambdas, an inert match type over a free
parameter, a refinement, a path-dependent member type, a name outside the
sources, and opaque representations, which stay `?` by contract.

The plan's baseline table, its V1.3 status and the README's eo paragraph follow
the same numbers.
CodeScene's gate read the merged Counter as three new issues: 792 lines of code
in one file, 117 functions in one module, and `instantiation` at a cyclomatic
complexity of 9. The file had grown with the new machinery rather than been
reorganised around it.

Three pieces move to where they belong, with no behaviour change:
`Scope.scala` carries what a read is made of (the names a body solves, the
world around them, and the library of other sources), `Walk.scala` carries the
walk that measures a source definition by definition, and `MatchTypes.scala`
carries the match-type reduction. `Library` becomes a top-level type of the
`cardinality` package, which is what it is — the report's view of a whole
source set. `instantiation` keeps the lookup and delegates the read to a named
helper, so neither half carries the whole complexity.

Verified by the suite (404 examples), both scripted scenarios, and the eo run:
the regenerated report is byte-identical to the baseline, so the split moved
code and nothing else.
`sealed trait Nat` read `—` in the report while a reference to it read `ω`:
the abstract row had no size of its own, even where the solver had a solved
sum. The same held for every sealed family in eo (`Affine`, `PSVec`,
`Optic`), whose consumers were reading numbers the parents' rows denied.

An abstract row now reads the value a reference to it has: the solved sum for
a sealed parent whose cases the source set defines — the children's reasons
landing on the parent's row, so a family with a parameter still says why — and
`—` only for an abstraction nobody summed. That exposed a second bug behind it:
`sealedSums` skipped a sealed parent when a *companion object* shared its name
(the trait was diffed away by the object's name), which is why the plugin
fixture's `sealed trait Shape` had no sum at all; the parent keeps its equation
and the type still claims the name.

Plugin fixtures, both scripted scenarios, the eo baseline and the plan's table
follow: `Nat` reads `ω`, eo's abstract rows carry their sums (81 with one
value, 6 without a number), and the suite is 404 examples green.
CodeScene's gate read the split-out `Scope` as primitive-obsessed: half of its
arguments were primitives and half of the arguments to its 45 functions were
strings. It is a name-keyed store — the heuristic is pointing at the key.

`TypeName` is that key, opaque the way this codebase treats a value it does not
want confused with its own text: a name *is* its string at runtime, and the
compiler keeps it apart from a package path, a printed signature or a report's
prose. `TypeName.of` is the boundary a printed name crosses, `TypeName.path` a
dotted package path, and `name.value` the text a row, an error or a sum needs.
The refactor reaches through the scope, the walk, the solver's name-keyed maps
and sets, and the match-type reducer; `bareName`/`bareTarget`/`initParent` stay
`String` on purpose — they compare *syntax*, not keys.

Verified by 404 examples, both scripted scenarios, and the eo run: the
regenerated report is byte-identical to the baseline, so this moved types and
nothing else.
CodeScene's gate read `Counter.instantiated` as an excess-argument function
(six, max four); the lookup's result travels as the tuple it already is, so the
read takes what it is reading plus where it came from.
Three rows of the eo carrier family were reported as gaps in the calculator
rather than as what they are:

- `type Forget[F[_]] = [X, A] =>> ForgetK[F, X, A]` names a *function* on
  types. It has no value space of its own — applying it to arguments is where
  a size would come from — so its row is `—` with the `constructor` kind and
  the summary counts type constructors apart from what is unresolved.
- `Optic[S, T, A, B, MultiFocus[PSVec]] { type X = Xo }` is the trait it
  refines plus member bindings: a refinement is now read as its base type, so
  `ComposedTraversal` reads `open to implementations (Optic)` — a fact about
  the sources rather than a missing rule.
- A qualified name a report cannot resolve is described as written (`af.Z`),
  since the owner is what a reader needs to look it up.

Not done, and named in the ledger rather than pretended: β-application of a
constructor alias and opaque transparency inside the defining package. Neither
is reachable from a *definition's* stored types in eo — its signatures use
them, and the method side's own model reads them.

eo-core: 13 unresolved → 10, 4 open → 5, 2 constructors. Suite: 406 examples,
both scripted scenarios, site renders; baseline, ledger, plan table and README
follow the new numbers.
`type X = af.Z` read `unresolved: Z`, losing the one thing a reader needs:
which instance's member it is. A qualified reference now resolves when the
sources supply the owner's type, and is reported as written otherwise.

- `Outer.B` (a named owner), `Foo[Boolean].B` (an applied one), and `x.B` over
  a value whose declared type the sources give: the member's body is read with
  the owner's parameters replaced by what the reference supplied.
- a member a body declares abstract (`type Z` with no body) is supplied by
  whoever implements the owner, so the reference is `open to implementations
  (x.Z)` rather than an unknown name.
- anything else — a qualifier whose type the sources do not give — keeps the
  reference as its reason: eo's `ComposedTraversal.X` now reads `unresolved:
  af.Z`.

Two fixes fell out of running it over eo. `Walk`/`Scope`/`MatchTypes` printed
trees without the Scala 3 dialect, so `Library.of` crashed on the first `inline
def` eo's sources contain (`Scala213 doesn't support inline modifiers`) — the
given now sits in every file that prints, as it already did in `Counter` and
`MethodAnalysis`. And a library frame is looked up by `members` as well as by
values and equations, so a trait can be the owner of a member.

eo-core: 10 rows unresolved (unchanged), one reason improved. Suite: 410
examples, both scripted scenarios, site renders.
… scope

Review notes: the modelled base types were a `Map` private to `Counter`
matched with a guard and a lookup, and the binder case asked the scope twice
whether it knew a name.

`BaseTypes` holds the table where it belongs and answers both ways: as the
extractor `case Type.Name(BaseTypes(size)) => size`, and as `get` for a caller
that is checking rather than matching. `Scope.Parameter` is the same idea for
the other question — `case scope.Parameter(size) => size` reads the binding
this read gives a type parameter, which is what the two-line guard was
spelling out.

Verified by 410 examples, both scripted scenarios, and the eo run: the report
is byte-identical, so this moved where the tables live and nothing else.
`BaseTypes.unapply` took the name, so `typeIn` still had to peel the `Type.Name`
itself before the extractor could see it. It takes the type now and delegates to
`get` once it knows the shape is a name, so the match reads
`case BaseTypes(size) => size` and the table keeps one lookup.

Verified by 410 examples, both scripted scenarios, and the eo run (unchanged).
An opaque type's row read `?` by policy while its own file could see the body:
`opaque type Id = Byte` sat next to genuine singletons and `Direct[X, A] = A`
said nothing at all. The policy was protecting against a misreading that the
number does not have to invite.

The row now reads the representation, in the scope that defines it: `Id` is
2^8, `Direct[X, A]` is `|A|` (instantiation-dependent), `ForgetK[F, X, A]` is
`F[A]` and `MultiFocusK[F, X, A]` is `(X, F[A])`. The `opaque` kind stays on
the row, so the contract is still visible: *outside* the defining scope a
reference is one opaque value, which is what the body's equation keeps saying
and what a new test pins for both readings at once.

eo-core: 10 unresolved → 7, 22 instantiation-dependent → 25. Suite: 411
examples, both scripted scenarios, site renders; baseline, ledger, plan table
and README follow.

Still named as missing, in the ledger: *reference-level* transparency — a field
inside the defining scope typed by the opaque still reads the outside
one-value binding.
`class User(id: Id, active: Boolean)` outside the scope that defines the opaque
`Id` is 1 × 2 — both halves were pinned (`CardinalitySpec`'s opaque case is one
opaque value, the product rule is `Pair`), the combination was not.
`ReportSpec.crossSourceOpaque` now reads the exact shape, and a new *pending*
target in `ArticleCardinalitySpec` pins the truth for the case the calculator
does not model yet: declared next to the opaque, `Id` is `Byte`, so `User` is
2^8 × 2 = 2^9 — it fails today at 2 and turns into an assertion when
reference-level transparency lands.
@kryptt
kryptt merged commit b41f0ea into main Oct 5, 2026
8 checks passed
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