Umbrella: the shape matrix — enumerate what crosses, per position and target (#198) - #402
Draft
milyin wants to merge 9 commits into
Draft
Umbrella: the shape matrix — enumerate what crosses, per position and target (#198)#402milyin wants to merge 9 commits into
milyin wants to merge 9 commits into
Conversation
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.
This was referenced Aug 13, 2026
…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
…#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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This body mirrors
docs/shape-matrix.mdon 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, notmain.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-matrixenumerates 332 cells (166 per target) across four axes and runs each through the realprebindgen-candprebindgen-jni, then through rustc, then through cbindgen, and — for three of them — through actual execution.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
rustcbecause 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:
tag_ofis an exhaustive match overTypeKind, so a new accepted Rust form stops the crate compiling until it has a fixture;prebindgen_jni::ClassDecl;REPORT.mdis committed and diffed byregen-check.sh, so every changed answer is a reviewed diff;GUARANTEES.mdholds 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:
Cowinto the consumer's scopeOption<T>by-value parameter&[T]drops the value,Vec<&T>maps over anunsafe fnOptioninto the source module, and leaves the declared type bareTwo 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
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:
()-returning C binding aborts on an alias rejection, because a panic cannot crossextern "C"— so the guard is unobservable in-process, and the fallible spelling is the only one whose rejection a caller can act on;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:
A blanket "the report never changes" would be false on contact and would train reviewers to wave the diff through.