Skip to content

Umbrella: the shape matrix — enumerate what crosses, per position and target (#198) - #402

Draft
milyin wants to merge 9 commits into
mainfrom
shape-coverage
Draft

Umbrella: the shape matrix — enumerate what crosses, per position and target (#198)#402
milyin wants to merge 9 commits into
mainfrom
shape-coverage

Conversation

@milyin

@milyin milyin commented Aug 13, 2026

Copy link
Copy Markdown
Owner

This body mirrors docs/shape-matrix.md on this branch, which is the one place step state is edited. Change the doc, then re-sync this body — never the other way round.

Draft, and long-lived. Child PRs target shape-coverage, not main.

Umbrella for #198, itself part of #399.

The problem

Which Rust shapes survive the trip to C or to Kotlin — Option<&T>, Vec<Option<T>>, a payload-carrying enum inside a struct — was knowable only by reading the generators, or by writing the Rust and finding out. A user discovered a gap by hitting it; a reviewer could not tell whether a change closed a hole or moved it; and a regression that quietly dropped a shape looked like nothing at all, because no test knew the shape was supported.

The answer is a generated table — one row per (shape, position, target), the answer produced by running the real generators. There is deliberately no second authority on legality anywhere in this work: a second opinion can disagree with the first, and then neither is trustworthy.

What is on the branch now

examples/shape-matrix enumerates 332 cells (166 per target) across four axes and runs each through the real prebindgen-c and prebindgen-jni, then through rustc, then through cbindgen, and — for three of them — through actual execution.

Target header rustc bad rust rejected panic n/a
C 65 0 5 42 32 22
Kotlin/JNI 0 95 6 34 9 22

The ladders differ by target and the report says so rather than levelling to the shorter one: C runs one stage further, and JNI stops at rustc because this branch does not run the Kotlin compiler.

Four axes: the value shape in a position; the call (aliasing is a property of a call, not a value); the declaration policy (the same Rust declared as something else); and the target.

Four gates, each of which fails rather than asks a reviewer to notice:

  • the type axis is derived, not listed — tag_of is an exhaustive match over TypeKind, so a new accepted Rust form stops the crate compiling until it has a fixture;
  • the declaration axis likewise, over prebindgen_jni::ClassDecl;
  • REPORT.md is committed and diffed by regen-check.sh, so every changed answer is a reviewed diff;
  • GUARANTEES.md holds a floor per cell — 171 of them — so a cell may rise freely and cannot fall silently. Raising is automatic; lowering is a hand edit, on purpose.

What it found

The table was never the deliverable. The tickets are:

Issue Finding
#410 JNI emits an unqualified Cow into the consumer's scope
#411 JNI decodes an exclusive-borrow parameter as a shared or plain value
#412 C moves out of a raw pointer for an Option<T> by-value parameter
#413 C returns of borrowed elements: &[T] drops the value, Vec<&T> maps over an unsafe fn
#414 C qualifies std Option into the source module, and leaves the declared type bare
#191 a third of C's refusals and a fifth of JNI's arrive as panics — now a measured number

Two carry a diagnosis rather than a symptom, and both came from comparisons no single cell could make: #412 is narrowed to the by-value decode path because the same cell with its type declared as a handle compiles, and #414 gets both halves of one expression's qualification wrong in opposite directions.

Three behavioural claims are established by running the code, not by inspecting it: the alias guard fires on a call naming one resource twice, spares one that does not, and runs before ownership moves — the last proven by reclaiming the resource afterwards, which would be a double free if a converter had run.

Steps

# Step State
1 The enumerator, both targets, committed report + regen gate landed (#400)
2 Receipts: rustc accepts the emitted Rust landed (#403)
2b Kotlin emitted, and cbindgen asked for the header landed (#405)
2c The runtime — C side, in pure Rust landed (#409)
2d The Kotlin compiler and the JVM runtime not started — needs kotlinc and a JVM in CI
3 The guarantee ratchet — a floor per cell landed (#406)
4 The call axis landed (#407)
5 The declaration-policy axis landed for the JNI vocabulary (#408); C's own declarators blocked on #192
6 Plan-level invariants blocked on #192 / #193

Everything self-contained is done. What remains is either an infrastructure commitment (2d: JNI wrappers are entered from a JVM, and unlike the C side there is no shortcut) or the plan-layer rework (5-C, 6).

A recurring result worth stating

Every stage that added a stronger kind of evidence immediately invalidated some of the weaker evidence beneath it — usually this harness's, not the generator's:

  • the compile stage found three fixtures that had been reporting confident wrong answers, including borrow-returning cells that could never have compiled;
  • the runtime stage found that a ()-returning C binding aborts on an alias rejection, because a panic cannot cross extern "C" — so the guard is unobservable in-process, and the fallible spelling is the only one whose rejection a caller can act on;
  • the policy axis found that one earlier finding belonged to the by-value path rather than to Option.

That is the argument for the receipt rule this work has followed throughout: a state that nothing mechanical established can only be wrong in the flattering direction.

Exits

Each step states which it is, up front:

  • answer-preserving — no cell state changes. A refactor of a measuring instrument must not move the measurements.
  • answers move — the diff is the review, and every moved cell is named in the PR body with its cause, because a moved cell is either a capability change or a regression and the diff alone cannot say which.

A blanket "the report never changes" would be false on contact and would train reviewers to wave the diff through.

The branch map for #198, and the one place its step state is edited — the
umbrella PR's body mirrors this file, not the reverse.

Unlike #187's chain, these steps are independently landable; the branch keeps
one reviewable thread for a change whose value is cumulative, not a place to
hide half-migrated states.
…198) (#400)

* The shape matrix: enumerate what crosses, by running the generators (#198)

Which Rust shapes survive the trip to C or to Kotlin — `Option<&T>`,
`Vec<Option<T>>`, a payload-carrying enum inside a struct — was knowable only by
reading the generators, or by writing the Rust and finding out. So a gap is
discovered by hitting it, a reviewer cannot tell whether a change closed a hole
or moved it, and a regression that quietly drops a shape looks like nothing.

`examples/shape-matrix` enumerates (shape × position × target), synthesizes a
fixture for each, and runs it through the **real** generators. There is no table
of expected support anywhere in it: a second authority on legality can disagree
with the first, and then neither is trustworthy.

**The type axis comes from the model, not from a list.** `tag_of` is an
exhaustive match over `TypeKind`, so a new accepted form stops this crate
compiling; `every_type_form_is_covered` then fails until the form also has a
fixture. Both halves are needed — a form could otherwise be tagged and never
enumerated. That is precisely how the hand-written grammar this replaces came to
name 8 of the 15 forms, and the same drift shipped once before: a spec listing
`Vec | [T] | Cow<[T]>` as the sequence forms let `[u8; 16]` degrade to an opaque
leaf until something needed it (#190). Verified to discriminate — deleting the
one `Box<u64>` fixture fails the test naming `Box<T>`.

`children` is a second exhaustive match rather than `TypeRef::walk`, which
descends *through* transparent wrappers by design: `Box<Vec<T>>` reaches `T`
without ever yielding the `Vec`, and coverage has to see every form written.

**Declarations are a separate axis**, because the model records a field's type as
"a named type called `Rec`" and stops. A cell about a struct field emits a
struct; one about a payload emits an enum.

**A panic is an outcome, not a crash.** Unsupported shapes are reported both ways
today — a named rejection, or a `panic!` — so the runner catches unwinds and the
table distinguishes them. That column is #191's evidence, per cell.

**The declaration policy is the documented one**, or the table would measure this
harness: C gets `.panic()` (the probe returns no `Result`, and a C binding with
fallible inputs must say what happens), and a `Result`'s error type is declared
the way each target documents — C `.opaque_error` with its message function, JNI
a handle whose declared return fields feed the generated error handler. Without
those two, 46 C cells reported this crate's omissions.

`REPORT.md` is committed and already covered by `regen-check.sh`, which CI runs.
That is the primary gate: the generators both decide legality and write the
report, so "fails until new cells are classified" is vacuous alone — a
regression flipping a working cell to `rejected` would be recorded as a
successful classification. A committed report makes every moved answer a
reviewed diff.

What it already shows, from 288 cells: a sum in a `Result` error position fails
in both targets (#162, open); a sum as a sum's payload panics in JNI; `Vec` of
handles panics rather than reporting; and C answers 35 cells by panic where JNI
answers 10.

What this does **not** claim: `plan` means generation succeeded. Nothing here
compiles, links or runs the result — `ToolchainCompiled` and `RuntimeExercised`
are separate states and are not collected yet.

Part of #198, tracked by #399.

* fmt: apply CI's import-granularity settings

CI runs rustfmt with `imports_granularity=Crate,group_imports=StdExternalCrate`;
a plain `cargo fmt` does not.

* Gate the declaration axis on the adapter's own class vocabulary

The type axis could not drift — `tag_of` is exhaustive over `TypeKind`. The
declaration axis could: a new way to declare a type would leave the matrix using
the old one, cells that had started passing would keep reporting `rejected` or
`panic`, and nothing would fail. An additive API change was the one class of
change this crate could not notice.

Half of it closes for free. `prebindgen_jni::ClassDecl` is a closed, public
four-variant enum, so `kind_of` is exhaustive over it and a fifth class kind is
a compile error here — the same chain the type axis has, one axis over. The
round-trip test then stops the two drifting the other way, by proving each kind
builds the declaration it names, and `every_class_kind_is_exercised` fails if a
kind is enumerated but never declared by any cell.

The harness therefore speaks the JNI vocabulary, and `to_c` translates it. That
direction is deliberate: the C build-script API is the older surface — eleven
declarator methods, no type unifying them — and it is to be reworked in the JNI
style (#192, #399). Shaping the harness around C's current spelling would bake a
quirk of the API being replaced into the thing meant to outlive it, so exactly
one function knows that spelling and is expected to be rewritten wholesale.

`Kind` also stops conflating two questions: a `Result`'s error type is not a
fifth class kind but a *role* a declared type plays, so `Decl` carries the class
kind and the error role separately.

Answer-preserving: `REPORT.md` gains the declaration-kind coverage table and not
one cell changed.

Part of #198, tracked by #399.
* Ask rustc whether the emitted Rust compiles (#198, step 2 in part)

`plan` said the generator produced a file. That is a weak claim: an emission can
be well-formed, contain every substring a unit test looks for, and still not
type-check — `examples/emitcheck` exists because that happened once with 41 of
41 tests green over it. Every cell that produced Rust is now compiled.

The state is a **receipt**, not a claim. Each cell is written to `<id>.rs`, the
whole crate is checked in one pass, and each diagnostic is attributed back by
the file rustc names. Nothing maps a cell to a fixture by hand — that mapping is
what let #175's test pass without creating its own precondition. Verified to
discriminate: `the_compile_check_separates_good_from_bad` feeds one compiling
and one non-compiling unit through the real path and requires them separated.

Compiler messages stay OUT of the committed report — they vary by toolchain, and
`cargo test --all` runs on both 1.85 and stable, so a message in the file would
make the report disagree with itself across jobs. Failing cells print their
diagnostics on stderr. The check crate's dependencies are pinned exactly for the
same reason: it has its own lockfile, so a caret range would let an upstream
release move a cell with nothing in this repo having changed.

**Exit: answers move.** 138 of 288 cells, in four classes:

* 120 `plan` -> `rustc` — the new evidence.
* 10 `plan` -> `bad rust` — emitted Rust that does not compile. See below.
* 8 -> n/a — `Option<&T>` and `Vec<&T>` in a field or payload. Those fixtures
  were never legal Rust; the borrow rule only excused a spelling *starting* with
  `&`, so a borrow nested inside one was being measured against a struct that
  needs a lifetime parameter to exist.
* no cell moved between `rejected` and `panic`.

Three harness defects found by turning the compiler on, each of which had been
producing a confident wrong answer:

* the source crate was mounted as `mod probe` beside a generated `pub fn probe`
  wrapper — two different things sharing a name, so it is `flat` now;
* a returned borrow has no lifetime to elide from, so `-> &Handle` is not Rust.
  Every borrow-returning cell had reported `plan` for a fixture that could not
  compile; the fixture writes `'static` now;
* `impl Display for ZError` was being fed to the *model*, which correctly refuses
  an item kind the flat language does not have — failing 32 cells for a reason
  unrelated to their shape. The model now sees the four item kinds a
  `#[prebindgen]` surface declares, the same filter `emitcheck` applies.

What the 10 findings are, all confirmed in the generator's own output rather
than in fixture scaffolding:

* JNI emits `Cow<'static, str>` **unqualified** into the consumer's scope, so a
  consumer that has not imported `Cow` cannot compile the file (3 cells);
* C emits `flat::Option<Handle>` — a std type qualified into the source module;
* C moves out of a value behind a raw pointer for `Option<data struct>` and
  `Option<sum>` parameters;
* C calls the source function with one argument too many for a `&[T]` return,
  and builds a `map`/`collect` over a `Vec<&T>` return whose closure is a
  function item of the wrong signature;
* JNI mismatches types for a `&mut T` parameter and for `&mut MaybeUninit<T>`.

Part of #198, tracked by #399.

* docs: step 2's rustc half has landed

Splits the receipts step in two: rustc accepts the emitted Rust (done), and the
rest of the toolchain plus the runtime states (not started).
…405)

* Generate both halves of each binding, and ask cbindgen for the header (#198, step 2b)

Two stages a cell was passing without ever reaching.

**JNI produced no Kotlin.** The driver called `write_rust` and stopped, so a
`rustc` cell had shown half of what the target emits — the half that is not what
a Kotlin caller ever sees. The binding now writes its Kotlin too, and a binding
whose Kotlin cannot be written, or that produces none at all, is a rejection like
any other. No cell moved: every JNI cell that emitted Rust also emits Kotlin, so
this buys no new state today and closes the hole where one would have hidden.

**C stopped at rustc, which is the wrong finish line.** What a C consumer gets is
a header, produced from the emitted Rust by cbindgen, and the two can disagree:
a signature rustc accepts can be one cbindgen skips or cannot name. `header` is
now the top C state, pinned to the same cbindgen version `example-cbindgen` uses
so this judges the header that example produces rather than another tool's
opinion of the same Rust.

The receipt is **not** "cbindgen returned `Ok`" — it returns `Ok` for a header
that declares nothing. It is that the wrapper is declared: the name followed by
an open parenthesis, since cbindgen carries doc comments through and a receipt
that passes on prose is not a receipt. `the_header_stage_requires_a_declaration`
is the discrimination check, with the negative control being the same Rust minus
`extern "C"` — still valid, still parsed happily, still useless to a C caller.

**Exit: answers move**, one class only: 45 cells `rustc` -> `header`. Every C
cell whose Rust compiles also yields a header declaring its wrapper, so the
stage adds evidence rather than findings. That is worth having anyway — it is
the difference between "no C cell is known to be broken here" and "no C cell has
been asked".

The ladders now differ by target, and the report says so rather than levelling
to the shorter one: C runs one stage further, JNI stops at rustc because this
crate does not run the Kotlin compiler.

Part of #198, tracked by #399.

* docs: step 2b landed, and what it leaves for 2c

The Kotlin is emitted but not compiled, so the JNI ladder still ends one stage
short of C's.

* docs: the step 2b PR is #405
…198, step 3) (#406)

* A floor per cell: shapes may improve, and may not quietly get worse (#198, step 3)

The committed report makes every changed answer a reviewed diff. That catches a
regression only if somebody reads the diff and knows which direction is bad —
and a diff shows `header` becoming `rejected` in exactly the same shade as the
reverse. The two questions are different: *"did anything move?"* and *"did
anything move **down**?"*, and only the second can be enforced without a
reviewer.

So each cell that produces something now carries a floor — the level it has been
seen to reach — in a committed `GUARANTEES.md`. 130 of them: 45 at `header`, 75
at `compiles`, 10 at `generates`. Rising above a floor is free and silent.
Falling below it fails a test naming the cell, the floor and the level it now
reaches.

**Raising is automatic, lowering is a hand edit.** `--update-guarantees` raises
floors and never lowers one. Giving up on a shape that used to work should cost
a line in a diff a reviewer can argue with, not a silently regenerated artifact —
which is exactly what would happen if the file were rewritten on every run.

The ladder is per target, because C runs one stage further than JNI: `header` is
C's ceiling, `compiles` is JNI's until the Kotlin compiler runs. A shared ceiling
would let a C cell lose its header without falling below any floor.

Four tests, three of which exist because a gate that has never been observed
failing is a gate nobody can trust:

* the ratchet reports exactly the cell that fell, and ignores the one that rose;
* `--update-guarantees` leaves a floor standing when the run does worse;
* every floor names a cell the matrix still enumerates, so a renamed shape cannot
  leave an entry behind being satisfied by nothing;
* and the gate itself, against the floors this repository commits.

Verified end to end as well as in unit form: claiming `header` for a JNI cell
fails with

    array_scalar__field__jni: was guaranteed to reach `header`, now reaches only `compiles`

`report::survey()` is memoized, so the report and the ratchet share one pass over
both generators rather than doubling the suite's cost to compute the same answer.

**Exit: answer-preserving.** `REPORT.md` is byte-identical; this stage adds a
second gate over the same measurements and moves none of them.

Part of #198, tracked by #399.

* docs: step 3 landed

The ratchet's third level waits on 2c: there is no runtime state for a floor to
stand on yet.
…4) (#407)

* The call axis: shapes that only exist between parameters (#198, step 4)

Every cell so far described one value in one position. Aliasing cannot be
described that way: two parameters can name the same underlying resource, and
what a binding must do about it is a property of the **call**. Both generators
already say so in their own code — each emits an alias preflight under a rule
about the whole parameter set (at least one consumed handle, and any other
handle), and both docs note that the narrower reading, "two consumed
parameters", misses the mixed cases.

So calls are a second kind of cell, running through the same driver, the same
compile check, the same header stage and the same ratchet. Eight of them, chosen
around that rule rather than at random: pairs that must be guarded
(consume/consume, consume/borrow, consume/optional), a pair that must **not** be
(borrow/borrow — two `&T` to one resource is legal Rust and legal C, and a guard
there would remove working surface), pairs in different resource domains where
the question does not arise, and one three-parameter call with the consume last,
since position within a call must not matter.

All eight are expressible in both targets: `header` for C, `rustc` for JNI. No
findings — the value is that a call shape becoming inexpressible now falls below
a floor, and that the fixtures exist and are named for the stage that can run
them.

**What no cell here claims.** Whether the guard *fires* — rejecting the aliased
call, sparing the unaliased one, running before ownership moves — is a claim
about running code. It is not asserted against emitted text: a grep for a guard
establishes that the text contains a guard, which is not the property anyone
cares about, and this crate has been careful to report only what something
mechanical established. Those three discriminations wait for the runtime stage.

**Exit: answers move**, additively only: 16 new cells, 8 `header` and 8 `rustc`.
No existing cell changed.

Two integrity tests, in the spirit of the ones already here: every call parameter
is a type the model accepts (or the call measures a fixture that never contained
the shape it names), and every cell id is unique — an id is a file name, a
receipt key and a guarantee key at once, so a collision would silently merge two
cells, one reporting the other's diagnostics and inheriting the other's floor.

Part of #198, tracked by #399.

* docs: step 4 landed, and the three claims it leaves to the runtime stage
… step 5) (#408)

* The declaration-policy axis: the same Rust, declared differently (#198, step 5, JNI vocabulary)

Every cell so far declared each type exactly one way — a struct as a value
struct, a handle as a handle. That is a **choice a binding author makes**,
separate from the Rust, and while it never varies, whether it decides the answer
is invisible.

Twelve cases, curated rather than exhaustive: a full product of shape × position
× kind would quadruple the matrix to ask a question that is only interesting
where a real binding has a real choice. Each row prints the varied answer beside
the canonical one, because the difference is the point.

Three rows where the policy decides the answer:

* `Vec<Rec>` as a **parameter**: crosses when `Rec` is a value struct, and JNI
  refuses it — by panic — when `Rec` is a handle. Same Rust, same position; the
  Vec-of-handles rule is about the declaration, and nothing in the value axis
  could have shown that.
* `Option<Rec>` as a C parameter: the canonical run emits Rust that does not
  compile (the move out of a raw pointer, already a finding), and declaring
  `Rec` as a handle **avoids it** — so that defect belongs to the by-value
  path, not to `Option`.
* A fieldless enum returned as an opaque handle: refused at compile time by a
  generated assertion — *"opaque handle types must have alignment >= 2 (bit 0 is
  the closed tag)"*. `Mode` is byte-aligned, so the tagged-pointer
  representation cannot hold it.

That last one is a **correct refusal**, and it exposed a gap in this report's
vocabulary rather than in the generator: a deliberate compile-time rejection and
genuinely broken output both land as `bad rust`, since the states come from
whether rustc accepted the file and messages are deliberately kept out of the
committed report. The legend now says so, and points at #191 — a refusal
arriving as a compile error rather than a named rejection at declaration time is
exactly that issue's subject.

**Scope.** The four kinds varied here are the JNI adapter's own closed
vocabulary, which is what makes the axis enumerable at all. C is measured
through the same four via the existing translation, so its rows are real but its
*coverage* is not: `repr_c_struct`, `opaque_data_struct`, `callback` and the
rest have nothing to enumerate against until the C build API is reworked (#192).

**Exit: answers move**, additively: 24 new cells, no pre-existing cell changed.

Integrity test in the spirit of the others: a policy case must actually vary a
declaration. A case whose override matches the canonical kind would print a row
duplicating the one beside it — proving nothing while looking like evidence.

Part of #198, tracked by #399.

* docs: step 5 landed for the JNI vocabulary
milyin added 2 commits August 14, 2026 01:50
…#198, step 2c) (#409)

* Run the generated C bindings, and settle what the call axis could not (#198, step 2c for C)

Every stage so far asked whether something could be *produced* — a plan, Rust
that compiles, a header that declares. The call axis raised three questions none
of them can answer: does the alias guard **fire** on a call naming one resource
twice, does it **spare** a call that does not, and does it run *before* ownership
moves. Those are claims about running code.

The C target needs no toolchain to answer them. Its `extern "C"` wrappers are
ordinary Rust functions, so calling one with the pointers a C caller would pass
is a `cargo test` — no C compiler, no linker, no CI change. All three hold.

The third is the one worth stating precisely: the aliased call is rejected **and
the resource is reclaimed afterwards**, which would be a double free if either
converter had run. That is what makes it a statement about ordering rather than
about failing.

Cases are a discrimination set, not a wish list. A guard that fired on
everything would pass the first and fail the second; one that fired on nothing
would do the reverse. Neither can pass both.

Four things this needed, each of which is a finding in its own right:

* **A `()`-returning C binding cannot report an alias rejection — it aborts.**
  A panic cannot cross `extern "C"`, so under `.panic()` the guard is fatal by
  construction and unobservable in-process. The cases therefore use fallible
  call shapes, whose rejection routes to the error out-param the way a real C
  API reports anything. Both spellings are now in the corpus, so the difference
  is visible rather than discovered again.
* **A fallible fixture must actually return.** A body that panicked would abort
  on the *accepted* path for the same reason.
* **The C error channel requires `From<String>` on the declared error type** —
  the binding's own messages (an alias rejection, a null handle) are converted
  through it. A requirement of the channel, not of any one shape.
* **The rejection's explanation reaches the caller intact**, which the first
  case now asserts on: the aliasing message survives conversion into the error
  type and back out through the accessor. An earlier fixture discarded it and
  the caller received `error 1`.

Each case is an **integration test**, so cargo compiles it as its own crate.
Not a style choice: every case exports the same `#[no_mangle]` symbols, and one
crate holding two of them does not link. The compile stage never hit this,
because `cargo check` does not generate code.

The receipt is the test harness's own per-test result line. A case that fails to
compile or never runs is `not run`, never a pass, and no table maps a case to
"presumed fine".

Kotlin/JNI has no equivalent: its wrappers are entered from a JVM, and reaching
them needs one.

**Exit: answers move**, additively — 4 new cells for the two fallible call
shapes, no pre-existing cell changed.

Part of #198, tracked by #399.

* docs: the C runtime landed; the JVM half is what remains
Five defects filed (#410-#414) and #191's premise turned into a measured number.
Two carry a diagnosis rather than a symptom, both from comparisons no single
cell could make.
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