From f3b5ba3be1df00f74408ba7c3bae9af75562c3a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 15:06:07 +0000 Subject: [PATCH] =?UTF-8?q?abi:=20minor=208=20=E2=80=94=20the=20register?= =?UTF-8?q?=20groupings=20served=20as=20data,=20not=20copied=20three=20tim?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire encoding of the §14 `carving` parameter lived in three places: a Rust `match`, Java's `Carving` enum, and abi.md's own table. Each was correct. Nothing would have failed if one had stopped agreeing with the others — and the failure mode is silent: a grouping added or reordered upstream re-maps one copy, a sweep then reads the same 12 bytes under the wrong reading, and returns a plausible number. One source, two derivations: 1. the contract owns the SET (CascadeShape::ROTATIONS); 2. kernels::CARVING_ORDER derives the ENCODING from it by a RULE (group count, descending — never declaration position), as a const; 3. the manifest SERVES the result: carving_count + carvings[8], packed (groups << 8) | group_bytes. No new symbol. The manifest already exists so Java can discover the ABI's shape rather than declare it, and a wire encoding is exactly such a shape. Java keeps its ARITY and loses its ENCODING: RAILS_6X2 named anything but 6x2 would be a lie in its own name, so groups()/groupBytes() stay declared, while wire()/ofWire() read the served table. The one literal encoding left is CarvingTable.PRE_MINOR_8, whose name says it is history. Also fixes a latent defect the change made reachable: Java's load gate required the FULL manifest layout, so this — the first growth of the manifest struct — would have made every older artifact fail to load, in flat contradiction of §2's additive promise. The gate now requires only the 104-byte base prefix; later fields are read when size_of_manifest covers them AND the minor is high enough. Gates: Rust 134 lib tests, fmt + clippy -D warnings clean; Java 304 checks (was 288); OldAbiCompatTest green against minors 1-4; no new javac warnings. Disable-runs, each red-then-green: swapped packed axes fail the serve test; a reversed sort fails the order test and two others; a mismatched Java arity fires BOTH membership directions; the restored full-layout gate makes the minor-4 library fail to load outright. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DCfrD5y19cvFc4AoyydXYv --- .claude/board/EPIPHANIES.md | 45 ++++++ .claude/board/LATEST_STATE.md | 35 +++++ .claude/board/PR_ARC_INVENTORY.md | 48 +++++++ docs/abi.md | 111 +++++++++++++- .../com/adaworldapi/lancegraph/Carving.java | 93 +++++++----- .../adaworldapi/lancegraph/CarvingTable.java | 55 +++++++ .../lancegraph/internal/ffm/Abi.java | 64 +++++++-- .../lancegraph/internal/ffm/Layouts.java | 36 ++++- .../com/adaworldapi/lancegraph/AllTests.java | 1 + .../lancegraph/CarvingTableTest.java | 136 ++++++++++++++++++ native/lgj-abi/src/abi.rs | 68 ++++++++- native/lgj-abi/src/kernels.rs | 135 ++++++++++++++--- 12 files changed, 763 insertions(+), 64 deletions(-) create mode 100644 java/src/main/java/com/adaworldapi/lancegraph/CarvingTable.java create mode 100644 java/src/test/java/com/adaworldapi/lancegraph/CarvingTableTest.java diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index b9a857e..0b3a729 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -4,6 +4,51 @@ > `**Status:**`/`**Confidence:**` line. A correction gets its own new, > dated entry that references the one it corrects — the storno rule. +## 2026-08-25 — E-LGJ-A-CONSTANT-COPIED-THREE-TIMES-HAS-NO-FALSIFIER-1 + +**Status:** SHIPPED — ABI minor 8, docs/abi.md §17. +**Confidence:** High on the mechanism; every guard disable-verified. + +The §14 carving wire encoding lived in three places: a Rust `match`, a +Java `enum`, and abi.md's own table. Each was correct. Nothing anywhere +would have failed if one had stopped agreeing with the others. + +**That is the finding, and it generalises past this constant.** Three +copies of one fact is not a documentation problem to be tidied — it is a +correctness problem with NO falsifier, and its failure mode is silent: +a variant added or reordered upstream re-maps one copy, a sweep then +reads the same 12 bytes under the wrong grouping, and returns a +plausible number. "Keep them in sync" is not a mechanism. + +The fix is not a fourth copy that checks the others. It is ONE source +(`CascadeShape::ROTATIONS`, the contract's) and two DERIVATIONS: the ABI +computes the encoding from it by a RULE (group count, descending) rather +than by declaration position, and the manifest serves the result to Java +so Java restates nothing. A reorder upstream cannot re-map the wire; an +addition upstream propagates. + +**The two corollaries worth keeping:** + +1. **Derive by a rule, not by position.** Had the order been "declaration + order of the enum", the derivation would have been just as automatic + and just as fragile — the drift would simply have moved upstream. +2. **Meaning is declared; encoding is served.** `RAILS_6X2` keeps its + arity as a literal, because the arity IS the constant's identity and a + name that lies about it is worse than a hardcode. Only the encoding — + which carries no meaning — became data. + +**And the change surfaced a latent defect one layer down**, which is the +usual reward for touching a boundary: Java's load gate required the FULL +manifest layout, so the first growth of that struct would have made every +older artifact fail to load, in flat contradiction of §2's additive +promise. It had been written that way since minor 1 and was unreachable +until now. Measured: restoring the full-layout gate makes all four +historical libraries fail outright. + +Cf. `E-LGJ-THE-RESOLVER-WAS-UPSTREAM-ALL-ALONG-1` — same shape one rung +up (the answer was already in the contract; the local version was the +copy). + ## 2026-08-25 — E-LGJ-THE-RESOLVER-WAS-UPSTREAM-ALL-ALONG-1 **Status:** SHIPPED — ABI minor 6 + the contract accessor + the ndarray diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 4b4b92e..79f1231 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,38 @@ +## 2026-08-25 — ABI minor 8: the register groupings are DATA, and the load gate stopped requiring the whole manifest + +- **The wire encoding of §14's `carving` is no longer written anywhere by + hand.** The contract owns the set (`CascadeShape::ROTATIONS`), + `kernels::CARVING_ORDER` (a `const`) derives the order by a RULE (group + count, descending — never declaration position), and the manifest serves + it in two new fields: `carving_count: u32` + `carvings: [u16; 8]` + (`(groups << 8) | group_bytes`). No new symbol; the manifest already + exists so Java can discover the ABI's shape rather than declare it. + `LgjAbiManifest` is now 128 bytes. +- **Java keeps its ARITY and loses its ENCODING.** `Carving.groups()` / + `groupBytes()` stay declared — the arity IS the constant's identity — + while `wire()`/`ofWire()` look up the served table. `CarvingTable` holds + the one clearly-named pre-minor-8 compatibility shim, so exactly one + place in the build carries a literal encoding and its name says it is + history. +- **A latent defect fixed on the way:** Java's load gate required the FULL + manifest layout, so the FIRST growth of that struct — this one — would + have made every older artifact fail to load, contradicting §2's additive + promise. The gate now requires only the 104-byte BASE PREFIX + (`Layouts.MANIFEST_BASE_BYTES`); later fields are read only when + `size_of_manifest` covers them AND the minor is high enough. +- **Gates:** Rust 134 lib tests, fmt + clippy `-D warnings` clean; Java + **304** checks (`AllTests`, was 288 — `CarvingTableTest` adds 16); + `OldAbiCompatTest` green against all four historical `.so`s (minors + 1-4). Four disable-runs, each red-then-green: swapped packed axes, + reversed sort, a mismatched Java arity (fires BOTH membership + directions), and the restored full-layout gate (minor-4 library fails to + load). +- **Docs:** `docs/abi.md` §17 (new), §2 (load-gate prefix), §14's table + regraded DESCRIPTIVE rather than normative. +- Stacked on PR #30 (minor 7); board entry in `PR_ARC_INVENTORY.md`, + finding in `EPIPHANIES.md` + (`E-LGJ-A-CONSTANT-COPIED-THREE-TIMES-HAS-NO-FALSIFIER-1`). + ## 2026-08-18 — PR-W8b (FACADE + GRAPH MIGRATION) — the mask-native correction reaches the Java surface ### Current surface changes (java/ + consumers/graph) diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 0db1966..8e28c7c 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -8,6 +8,54 @@ > anti-pattern the imported board rules name. Backfilled below in one > pass rather than left stale; PR #4 onward gets its entry at merge time. +## PR #32 — ABI minor 8: the register groupings served as DATA (draft, opened 2026-08-25) + +- **Added:** `LgjAbiManifest.{carving_count, carvings[8]}` (docs/abi.md + §17) — the §14 wire encoding, previously hand-written in THREE places + (a Rust `match`, Java's `Carving` enum, §14's table) with nothing that + would fail if they disagreed. Now one source and two derivations: the + contract owns the SET (`CascadeShape::ROTATIONS`), `kernels::CARVING_ORDER` + (a `const`, group count descending) derives the ENCODING, and the manifest + serves it. Java's `Carving` keeps its declared ARITY (the name IS the + arity) and looks its wire value up in the served table. + New: `CarvingTable` (with the one clearly-named pre-minor-8 shim), + `CarvingTableTest` (16 checks, in `AllTests`), two Rust falsifiers. +- **Locked:** meaning is declared, encoding is served. A variant REORDER + upstream cannot re-map the wire (position is computed from `groups()`, + not declaration order); a variant ADDED upstream appears automatically + and is caught by the both-ways membership test rather than surfacing on + someone's data. `CARVING_ORDER` is `const` and not `LazyLock` because + the manifest that serves it is const-initialised. +- **Also fixed (a real latent defect, not scope creep):** Java's load gate + required the FULL manifest layout, so this — the first growth of the + manifest struct — would have made every older artifact fail to load, + contradicting §2's additive promise. Gate now requires only the 104-byte + BASE PREFIX; later fields are read when `size_of_manifest` covers them + AND the minor is high enough. Measured: all four historical `.so`s + (minors 1-4) still load and gate per-minor correctly. +- **Deferred:** nothing new. `FacetSchema`'s third reading is still + `Pair48` rather than the operator-ruled L6 quads — flagged earlier, + untouched here. +- **Docs:** `docs/abi.md` §17 (new), §2 (load-gate prefix + minor-8 + history), §14 table regraded DESCRIPTIVE, manifest struct listing, + header constants. +- **Gates:** Rust 134 lib tests / fmt / clippy `-D warnings` clean; Java + 304 checks (`AllTests`, was 288); `OldAbiCompatTest` 4/7/7/7 against + minors 1-4. Stacked on PR #30 (minor 7). +- **Disable-runs, all red-then-green:** swapping the packed axes fails the + Rust serve test; reversing the sort fails the order test and two others; + changing one Java constant's arity fails BOTH membership directions; + restoring the full-layout load gate makes the minor-4 library fail to + load outright. +- **Confidence:** high on the derivation and the falsifiers (each disabled + and observed red). Medium on the 8-slot table width — the bound is + argued from `G·D = 12`'s divisors, not measured against a future + `CascadeShape`. +- **Owned mistake:** mid-session I ran `git checkout` on `kernels.rs` to + undo a disable-run edit and destroyed the uncommitted work in that file. + Reconstructed and re-verified (134 tests, same count). Disable-runs are + now backed up to a file first, never reverted with `git checkout`. + ## PR #20 — D-LGJ-W8 A3 freeze (PR-0): ratified correction spec v3 + root CLAUDE.md + board storno (merged 2026-08-18, squash `c479f76`) - **Added:** `.claude/plans/mask-native-navigation-correction-v1.md` diff --git a/docs/abi.md b/docs/abi.md index 97446e2..a412c38 100644 --- a/docs/abi.md +++ b/docs/abi.md @@ -62,7 +62,8 @@ cannot disagree with itself. The ABI is a **machine membrane**. It is not the product. The product is the Java semantic API (see `architecture.md`). Therefore: -- It is **small** — currently 24 symbols (minor 7; the "14" this line carried +- It is **small** — currently 24 symbols (unchanged at minor 8, which adds + manifest FIELDS and no symbol; the "14" this line carried at minor 1 was arithmetic drift — the §7 list it referred to already enumerated 15). Growth is a design smell to be argued for, not a default; minor 2's three additions are argued in §11, minor 3's one addition in @@ -79,7 +80,7 @@ semantic API (see `architecture.md`). Therefore: ``` LGJ_ABI_MAJOR = 0 // incompatible change ⇒ bump; Java refuses to load -LGJ_ABI_MINOR = 7 // additive change ⇒ bump; older Java may still load +LGJ_ABI_MINOR = 8 // additive change ⇒ bump; older Java may still load LGJ_MAGIC = 0x4C_47_4A_5F_41_42_49_00 // "LGJ_ABI\0" big-endian-read ``` @@ -98,6 +99,18 @@ against an older `.so`**. That case is governed by `Abi.requireMinor(N)`, whose contract is to fail before the feature's downcall is attempted, naming the minor. +The **load gate itself** requires only the manifest's BASE PREFIX — everything +through `build_profile`, 104 bytes, the field set minor 1 defined +(`Layouts.MANIFEST_BASE_BYTES`). Requiring the full layout this Java build knows +about would make every future manifest field a hard incompatibility with every +older artifact, in flat contradiction of the promise above. It was written that +way until minor 8 grew the struct and the contradiction became reachable; +measured, restoring the full-layout gate makes all four historical libraries +fail to load outright. Fields past the base prefix are read only when the +library's own `size_of_manifest` covers them AND its minor is high enough — both +conditions, because a manifest that claims a minor it is too short to carry is a +broken artifact and reading it would produce plausible garbage. + **That guard was defeated by eager class initialization until 2026-08-25.** Every downcall handle was resolved in `Downcalls.`, so a single absent symbol broke the whole class and the guard never ran. Measured with the Java of @@ -132,6 +145,11 @@ required — a gate that rejected everything would satisfy a rejection-only test - **Minor 4** (2026-08-18, D-LGJ-W8) — `lgj_mask_andnot` (mask complement) and `lgj_hop` (one-hop graph traversal, gated by the `lance-graph-contract` `ClassView`/`FieldMask` LAW — §13). +- **Minor 8** (2026-08-25) — the manifest carries the register groupings as + DATA (§17): `carving_count` + `carvings`. **No new symbol** and no new + status; it is the first growth of the manifest STRUCT, which is why it is + also the change that made Java's load gate require only the base 104-byte + prefix rather than the full layout. - **Minor 7** (2026-08-25) — `lgj_row_layout_probe` (§16): the whole-row alignment answer, all 32 facets in one crossing. No new status. - **Minor 6** (2026-08-25) — `lgj_reduce_facet_sum_resolved` (§15): the same @@ -299,6 +317,10 @@ pub struct LgjAbiManifest { pub simd_backend: u32, // LgjSimdBackend pub simd_backend_name: [u8; 32], // NUL-terminated, human-readable pub build_profile: [u8; 16], // "release" | "debug" + // ── minor 8; everything above is the 104-byte BASE PREFIX the load gate + // requires, and all a pre-minor-8 artifact carries (§17) ── + pub carving_count: u32, + pub carvings: [u16; 8], // (groups << 8) | group_bytes, wire order } ``` @@ -796,6 +818,13 @@ bytes: | `1` | SPO triplets | `4 × (u8:u8:u8)`, LE `u24` zero-extended | | `2` | odoo quads | `3 × (u8:u8:u8:u8)`, LE `u32` zero-extended | +**Since minor 8 this table is DESCRIPTIVE, not normative** — see §17. The +encoding is derived from the contract's `CascadeShape::ROTATIONS` (group count, +descending) and SERVED in the manifest; a reader that needs the authoritative +answer reads `carvings`, and this row set is what that derivation currently +produces. Before minor 8 it was one of three hand-written copies, which is the +problem §17 exists to remove. + Anything else is `LGJ_ERR_UNSUPPORTED_CARVING` (`-15`), checked **first**, before the store or mask are resolved, so `out_sum` is provably untouched on a rejected call. An unknown reading must never alias a known one. @@ -1067,3 +1096,81 @@ which the address space does not permit. And the table captures **layout only**. Meaning, RBAC, ontology category and render template are separate resolutions off the same address; none belong in it and none can be inferred from it. + +--- + +## 17. The register groupings, served as data (ABI minor ≥ 8) + +The manifest grew two fields. No symbol, no status, no call: + +``` +u32 carving_count // populated entries in `carvings` +u16 carvings[8] // entry w = wire value w, packed (groups << 8) | group_bytes +``` + +Entries past `carving_count` are zero, so a reader that trusts the count and one +that scans for a terminator agree. The struct is 128 bytes (108 + 16 = 124, +rounded to its 8-byte alignment). + +### Why this exists + +The wire encoding of §14's `carving` parameter was hand-written in **three** +places — a Rust `match`, a Java `enum`, and §14's own table — with nothing that +would fail if they disagreed. Three copies of one fact is not a documentation +problem; it is a correctness problem with no falsifier, and the specific failure +it invites is silent: a grouping added or reordered upstream re-maps one copy and +not the others, and a sweep then reads the same 12 bytes under the wrong reading +and returns a plausible number. + +So the fact now has one source and two derivations: + +1. **The contract owns the SET.** `lance_graph_contract::facet::CascadeShape::ROTATIONS`. +2. **This ABI derives the ENCODING from it** — group count, descending + (`kernels::CARVING_ORDER`, a `const`). A variant REORDER upstream cannot + re-map the wire, because position is computed from `groups()` rather than + from declaration order. A variant ADDED upstream appears automatically, in + its group-count place, with no edit. +3. **The manifest SERVES the result**, and Java reads it rather than restating + it. + +`CARVING_ORDER` is deliberately a `const` and not a `LazyLock`: the manifest is +const-initialised, and a runtime-initialised order could not be reached from it. + +### Why the manifest rather than a new symbol + +The manifest already exists so Java can discover the ABI's SHAPE instead of +declaring it — sizes, alignments, pointer width, byte order. A wire encoding is +exactly such a shape. Serving it here costs no symbol, no crossing at call time, +and no lifetime question (a fixed `u16[8]` rather than a pointer), and it arrives +on the same read Java already performs at load. + +### What is still declared on the Java side, and why that is correct + +`Carving`'s ARITY stays declared: `RAILS_6X2` named anything other than `6 × 2` +would be a lie in its own name. What is no longer declared is its wire value — +that is looked up in the served table by arity. Meaning is declared; encoding is +served. + +### The falsifiers + +- Rust, `the_manifest_serves_exactly_the_derived_carving_order` — the served + bytes against the derived order, including that each entry decodes back to its + own shape, so a table that is internally consistent but wrongly ORDERED fails. +- Rust, `the_derived_order_is_strictly_descending_by_group_count` — a future + variant that TIED on group count would make the sort order-dependent again, + which is the property the derivation exists to remove. +- Java, `CarvingTableTest` — membership **both** ways. A grouping served that + Java cannot name (an addition upstream) and a grouping Java names that is not + served (a removal, or a locally invented constant). Neither direction is + redundant: without them, either mismatch would surface only when a particular + row happened to resolve to it — on someone's data, not in the build. + +Verified red-then-green: swapping the packed axes fails the Rust serve test; +reversing the sort direction fails the order test and two others; changing one +Java constant's arity fails both membership directions. + +A library predating minor 8 serves no table. Java falls back to the encoding +those artifacts actually used, in one clearly-named compatibility shim +(`CarvingTable.PRE_MINOR_8`) rather than back in the enum — so exactly one place +in the build carries a literal encoding, and its name says it is history rather +than the current answer. diff --git a/java/src/main/java/com/adaworldapi/lancegraph/Carving.java b/java/src/main/java/com/adaworldapi/lancegraph/Carving.java index 41f7de8..a3be67b 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/Carving.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/Carving.java @@ -28,57 +28,86 @@ */ public enum Carving { /** {@code 6 x (u8:u8)} — six little-endian {@code u16} rails. */ - RAILS_6X2(0), + RAILS_6X2(6, 2), /** {@code 4 x (u8:u8:u8)} — four little-endian {@code u24} SPO triplets, zero-extended. */ - TRIPLETS_4X3(1), + TRIPLETS_4X3(4, 3), /** {@code 3 x (u8:u8:u8:u8)} — three little-endian {@code u32} quads, zero-extended. */ - QUADS_3X4(2); + QUADS_3X4(3, 4); - private final int wire; + private final int groups; + private final int groupBytes; - Carving(int wire) { - this.wire = wire; + Carving(int groups, int groupBytes) { + this.groups = groups; + this.groupBytes = groupBytes; } /** - * The ABI wire value (docs/abi.md §14). Package-private on purpose: a consumer names the - * reading, never its encoding. + * Groups per register under this reading. {@code groups() * groupBytes() == 12}, always. + * + *

Declared, not looked up, and that is the one thing here that should be: the + * arity IS the constant's identity — {@code RAILS_6X2} named anything other than {@code 6 x 2} + * would be a lie in its own name. What is NOT declared is the wire value (see {@link #wire()}), + * because that is an encoding rather than a meaning. + */ + public int groups() { + return groups; + } + + /** Bytes per group under this reading. */ + public int groupBytes() { + return groupBytes; + } + + /** + * The ABI wire value (docs/abi.md §14) — read from the library's manifest table, not + * declared here. Package-private on purpose: a consumer names the reading, never its + * encoding. + * + *

The encoding used to be hand-written in three places — a Rust map, this enum, and + * {@code abi.md}'s table — with nothing to catch drift between them. Now the native side + * derives it from the contract's own {@code CascadeShape::ROTATIONS} (group count descending), + * serves it in the manifest, and this looks its own arity up in what was served. A reorder or + * an addition upstream propagates; it cannot silently re-map. + * + * @throws IllegalStateException if the loaded library serves no slot for this arity — a + * grouping this Java build knows and that library does not, which must fail rather than be + * approximated */ int wire() { - return wire; + int[] table = CarvingTable.get(); + int packed = (groups << 8) | groupBytes; + for (int w = 0; w < table.length; w++) { + if (table[w] == packed) { + return w; + } + } + throw new IllegalStateException("the loaded library serves no wire value for " + this + + " (" + groups + "x" + groupBytes + "); its carving table is " + + CarvingTable.describe(table)); } /** * The reading a wire value names — the inverse of {@link #wire()}, used when the native side * REPORTS which grouping it resolved. * - * @throws IllegalArgumentException on a value this build does not know, so an unrecognised + * @throws IllegalArgumentException on a value this build cannot name, so an unrecognised * grouping can never be silently read as a known one */ static Carving ofWire(int wire) { - for (Carving c : values()) { - if (c.wire == wire) { - return c; + int[] table = CarvingTable.get(); + if (wire >= 0 && wire < table.length) { + int packed = table[wire]; + for (Carving c : values()) { + if (((c.groups << 8) | c.groupBytes) == packed) { + return c; + } } + throw new IllegalArgumentException("the loaded library's wire value " + wire + + " names a " + (packed >>> 8) + "x" + (packed & 0xFF) + " grouping, which this" + + " Java build has no constant for"); } - throw new IllegalArgumentException("no carving for wire value " + wire); - } - - /** Groups per register under this reading. {@code groups() * groupBytes() == 12}, always. */ - public int groups() { - return switch (this) { - case RAILS_6X2 -> 6; - case TRIPLETS_4X3 -> 4; - case QUADS_3X4 -> 3; - }; - } - - /** Bytes per group under this reading. */ - public int groupBytes() { - return switch (this) { - case RAILS_6X2 -> 2; - case TRIPLETS_4X3 -> 3; - case QUADS_3X4 -> 4; - }; + throw new IllegalArgumentException("no carving for wire value " + wire + + "; the loaded library's table is " + CarvingTable.describe(table)); } } diff --git a/java/src/main/java/com/adaworldapi/lancegraph/CarvingTable.java b/java/src/main/java/com/adaworldapi/lancegraph/CarvingTable.java new file mode 100644 index 0000000..e951ebf --- /dev/null +++ b/java/src/main/java/com/adaworldapi/lancegraph/CarvingTable.java @@ -0,0 +1,55 @@ +package com.adaworldapi.lancegraph; + +import com.adaworldapi.lancegraph.internal.ffm.Abi; + +/** + * The register-grouping table the loaded library serves, as data. + * + *

Entry {@code w} is the wire value {@code w}, packed {@code (groups << 8) | groupBytes}. The + * native side derives it from the contract's own {@code CascadeShape::ROTATIONS} ordered by group + * count descending, so the SET is the contract's, the ENCODING is derived from it, and neither is + * hand-written on this side. + * + *

Package-private, and not a public view of the ABI. A consumer names a + * {@link Carving}; the encoding is this package's business. + */ +final class CarvingTable { + + private CarvingTable() {} + + /** + * The encoding a library predating ABI minor 8 used, restated once, here. + * + *

Those artifacts serve no table, and a Java build that refused to talk to them would break + * docs/abi.md §2's additive promise for a purely cosmetic reason — the encoding they use is + * known, fixed, and shipped. It is written here rather than back in {@link Carving} so that + * exactly one place in this build carries a literal encoding, and that place says in its name + * that it is a compatibility shim rather than the current answer. + */ + private static final int[] PRE_MINOR_8 = { + (6 << 8) | 2, (4 << 8) | 3, (3 << 8) | 4, + }; + + /** The served table, or the pre-minor-8 encoding when the library serves none. */ + static int[] get() { + int[] served = Abi.manifest().carvings(); + return served.length == 0 ? PRE_MINOR_8 : served; + } + + /** True when the table came from the library rather than the compatibility shim. */ + static boolean isServed() { + return Abi.manifest().carvings().length != 0; + } + + /** Human-readable, for the failure messages that report a table this build cannot match. */ + static String describe(int[] table) { + StringBuilder b = new StringBuilder("["); + for (int w = 0; w < table.length; w++) { + if (w > 0) { + b.append(", "); + } + b.append(w).append("=>").append(table[w] >>> 8).append('x').append(table[w] & 0xFF); + } + return b.append(']').toString(); + } +} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Abi.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Abi.java index 2f6c818..570fa2c 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Abi.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Abi.java @@ -68,7 +68,11 @@ public record Manifest( int endianness, int simdBackend, String simdBackendName, - String buildProfile) {} + String buildProfile, + // Packed (groups << 8) | groupBytes per wire value, in wire order. Empty on a library + // predating ABI minor 8, which served no table. (A record component takes no javadoc + // of its own -- javac's dangling-doc-comments lint is right about that.) + int[] carvings) {} private static final Path LIBRARY_PATH; private static final SymbolLookup LOOKUP; @@ -316,17 +320,22 @@ private static Manifest readAndVerifyManifest(SymbolLookup lookup, Path path) { } int sizeOfManifest = prefix.get(ValueLayout.JAVA_INT, Layouts.OFF_SIZE_OF_MANIFEST); - long expectedManifestSize = Layouts.MANIFEST.byteSize(); - if (sizeOfManifest < expectedManifestSize) { + // The gate is the BASE prefix, not the full layout. The manifest grows with the ABI, and + // requiring every field this Java build knows about would make each new one a hard + // incompatibility with every older artifact — the exact opposite of docs/abi.md §2's + // additive promise, and it would break OldAbiCompatTest's premise that an older library + // still loads. Fields past the base prefix are read only when the library carries them. + long baseBytes = Layouts.MANIFEST_BASE_BYTES; + if (sizeOfManifest < baseBytes) { throw new AbiMismatchException(String.format( - "manifest field 'size_of_manifest' disagrees: library reports %d bytes, this" - + " Java build's MemoryLayout derives %d. Reading the remaining fields" - + " would read past the artifact's own struct, so no further field is" - + " read. Library: %s", - sizeOfManifest, expectedManifestSize, path)); + "manifest field 'size_of_manifest' disagrees: library reports %d bytes, but the" + + " ABI's base manifest prefix is %d. Reading the base fields would read" + + " past the artifact's own struct, so no further field is read." + + " Library: %s", + sizeOfManifest, baseBytes, path)); } - MemorySegment m = raw.reinterpret(Math.max(sizeOfManifest, expectedManifestSize)); + MemorySegment m = raw.reinterpret(sizeOfManifest); // Every remaining check compares a number the artifact emitted against a number derived // from Layouts — never a constant against itself. @@ -363,14 +372,49 @@ private static Manifest readAndVerifyManifest(SymbolLookup lookup, Path path) { String profile = cString(m, Layouts.OFF_BUILD_PROFILE, Layouts.BUILD_PROFILE_BYTES); + int[] carvings = readCarvings(m, minor, sizeOfManifest); + return new Manifest(magic, major, minor, sizeOfManifest, (int) Layouts.LANE_DESC.byteSize(), (int) Layouts.OP_DESC.byteSize(), (int) Layouts.RESOURCE_INFO.byteSize(), (int) Layouts.LANE_DESC.byteAlignment(), (int) Layouts.OP_DESC.byteAlignment(), (int) Layouts.RESOURCE_INFO.byteAlignment(), - (int) ValueLayout.ADDRESS.byteSize(), endianness, backend, backendName, profile); + (int) ValueLayout.ADDRESS.byteSize(), endianness, backend, backendName, profile, + carvings); } + /** + * The register groupings the library serves, as packed {@code (groups << 8) | groupBytes} + * entries in wire order — empty on a library predating ABI minor 8. + * + *

Both conditions are checked, not just the minor: a manifest that claims minor 8 but is + * too short to hold the table is a broken artifact, and reading it would produce plausible + * garbage rather than a failure. Whether the table AGREES with this Java build's own + * {@code Carving} vocabulary is a separate question, answered by the drift falsifier in + * {@code CarvingTableTest} — this method only reports what the artifact said. + */ + private static int[] readCarvings(MemorySegment m, int minor, int sizeOfManifest) { + long end = Layouts.OFF_CARVINGS + (long) Layouts.CARVING_SLOTS * Short.BYTES; + if (minor < LGJ_ABI_MINOR_CARVINGS || sizeOfManifest < end) { + return new int[0]; + } + int count = m.get(ValueLayout.JAVA_INT, Layouts.OFF_CARVING_COUNT); + if (count < 0 || count > Layouts.CARVING_SLOTS) { + throw new AbiMismatchException( + "manifest field 'carving_count' is " + count + ", outside 0.." + + Layouts.CARVING_SLOTS + "; the table cannot be read"); + } + int[] out = new int[count]; + for (int i = 0; i < count; i++) { + out[i] = Short.toUnsignedInt( + m.get(ValueLayout.JAVA_SHORT, Layouts.OFF_CARVINGS + (long) i * Short.BYTES)); + } + return out; + } + + /** The minor at which the manifest began serving the carving table. */ + private static final int LGJ_ABI_MINOR_CARVINGS = 8; + private static void expect(Path path, String field, long javaDerived, int libraryReports) { if (javaDerived != libraryReports) { throw new AbiMismatchException(String.format( diff --git a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java index 233d489..b1323a8 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/internal/ffm/Layouts.java @@ -112,6 +112,15 @@ private Layouts() {} /** Length of the manifest's {@code build_profile} byte array. */ public static final int BUILD_PROFILE_BYTES = 16; + /** + * Slots in the manifest's {@code carvings} table (ABI minor 8). + * + *

A fixed-width array, not a pointer, because the whole table is 16 bytes and a pointer + * would add a lifetime question to a struct that deliberately has none. {@code carving_count} + * says how many slots are populated; the rest are zero. + */ + public static final int CARVING_SLOTS = 8; + public static final StructLayout MANIFEST = MemoryLayout.structLayout( ValueLayout.JAVA_LONG.withName("magic"), ValueLayout.JAVA_INT.withName("abi_major"), @@ -129,7 +138,13 @@ private Layouts() {} MemoryLayout.sequenceLayout(SIMD_NAME_BYTES, ValueLayout.JAVA_BYTE) .withName("simd_backend_name"), MemoryLayout.sequenceLayout(BUILD_PROFILE_BYTES, ValueLayout.JAVA_BYTE) - .withName("build_profile")) + .withName("build_profile"), + // ── added at ABI minor 8 ── + ValueLayout.JAVA_INT.withName("carving_count"), + MemoryLayout.sequenceLayout(CARVING_SLOTS, ValueLayout.JAVA_SHORT) + .withName("carvings"), + // repr(C) rounds the struct up to its 8-byte alignment: 108 + 16 = 124 -> 128. + MemoryLayout.paddingLayout(4)) .withName("LgjAbiManifest"); // The manifest is read by OFFSET rather than through a struct VarHandle, and that is a @@ -153,6 +168,21 @@ private Layouts() {} public static final long OFF_SIMD_BACKEND = off("simd_backend"); public static final long OFF_SIMD_BACKEND_NAME = off("simd_backend_name"); public static final long OFF_BUILD_PROFILE = off("build_profile"); + public static final long OFF_CARVING_COUNT = off("carving_count"); + public static final long OFF_CARVINGS = off("carvings"); + + /** + * Bytes of the manifest a MINOR-1 library is guaranteed to carry — everything through + * {@code build_profile}. + * + *

This is the prefix the load gate may require, and requiring more would break docs/abi.md + * §2's additive promise in the direction that matters here: a library OLDER than this Java + * build still loads, and each later minor gates independently at its own call site + * ({@code Abi.requireMinor}). Gating the load on the FULL layout size would have turned every + * future manifest field into a hard incompatibility with every older artifact — including the + * ones {@code OldAbiCompatTest} runs against. + */ + public static final long MANIFEST_BASE_BYTES = OFF_BUILD_PROFILE + BUILD_PROFILE_BYTES; private static long off(String name) { return MANIFEST.byteOffset(PathElement.groupElement(name)); @@ -278,6 +308,10 @@ private static boolean selfCheck() { // silently disagree. expect("LgjAbiManifest simd_backend_name offset", 56, OFF_SIMD_BACKEND_NAME); expect("LgjAbiManifest build_profile offset", 88, OFF_BUILD_PROFILE); + expect("LgjAbiManifest base prefix bytes", 104, MANIFEST_BASE_BYTES); + expect("LgjAbiManifest carving_count offset", 104, OFF_CARVING_COUNT); + expect("LgjAbiManifest carvings offset", 108, OFF_CARVINGS); + expect("LgjAbiManifest size", 128, MANIFEST.byteSize()); expect("LgjAbiManifest align", 8, MANIFEST.byteAlignment()); return true; } diff --git a/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java b/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java index 36a75a9..43463dc 100644 --- a/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java +++ b/java/src/test/java/com/adaworldapi/lancegraph/AllTests.java @@ -30,6 +30,7 @@ public static void main(String[] args) { suites.put("RowStoreLifetimeTest", RowStoreLifetimeTest::run); suites.put("MaskNativeOpsTest", MaskNativeOpsTest::run); suites.put("FacetSumParityTest", FacetSumParityTest::run); + suites.put("CarvingTableTest", CarvingTableTest::run); if (!NativeRuntime.isAvailable()) { // ApiSurfaceTest needs no native library — the API's shape is a compile-time property — diff --git a/java/src/test/java/com/adaworldapi/lancegraph/CarvingTableTest.java b/java/src/test/java/com/adaworldapi/lancegraph/CarvingTableTest.java new file mode 100644 index 0000000..ca18f97 --- /dev/null +++ b/java/src/test/java/com/adaworldapi/lancegraph/CarvingTableTest.java @@ -0,0 +1,136 @@ +package com.adaworldapi.lancegraph; + +import com.adaworldapi.lancegraph.internal.ffm.Abi; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * The DRIFT FALSIFIER for the register groupings served as data (ABI minor 8). + * + *

The encoding used to be hand-written in three places — a Rust map, {@link Carving}, and + * {@code abi.md}'s table — with nothing to catch them disagreeing. Now the native side derives it + * from the contract's own {@code CascadeShape::ROTATIONS} and SERVES it in the manifest, and Java + * reads what was served. That only helps if something checks the two sides still describe the same + * set — otherwise "derived" just moves the hand-written copy one file over. + * + *

So this suite compares MEMBERSHIP both ways, and neither direction is redundant: + * + *

    + *
  • A grouping the library serves that Java cannot name — a variant ADDED upstream. Without + * this check it would be silently unreachable: {@link Carving#ofWire} would throw only if a + * row happened to resolve to it, i.e. on someone's data, not in the build. + *
  • A grouping Java names that the library does not serve — a variant REMOVED upstream, or a + * Java constant invented locally. Without this check {@link Carving#wire()} would throw only + * when that constant was actually passed. + *
+ * + *

It also pins the property the wire ORDER rests on: the served table is strictly descending by + * group count. That is what makes a variant reorder upstream unable to re-map the encoding, and it + * is a fact about the served bytes here, not a restatement of the Rust rule. + */ +public final class CarvingTableTest { + + private CarvingTableTest() {} + + public static void main(String[] args) { + System.out.println("CarvingTableTest"); + if (!NativeRuntime.isAvailable()) { + System.exit(Checks.reportUnavailable("CarvingTableTest")); + } + Checks c = new Checks("CarvingTableTest"); + run(c); + System.exit(c.report()); + } + + public static void run(Checks c) { + int[] served = Abi.manifest().carvings(); + + c.section("the library serves the table (ABI minor 8)"); + // Below minor 8 there is nothing to compare, and a suite that quietly passes when its + // subject is absent is worse than one that says so. + if (served.length == 0) { + c.that("SKIPPED: library minor " + Abi.manifest().abiMinor() + + " serves no carving table (minor 8 added it); the pre-minor-8 encoding is in" + + " use and nothing here can be falsified", true); + return; + } + c.that("carvings served: " + CarvingTableDescribe.of(served), served.length > 0); + + c.section("membership agrees BOTH ways — an addition or a removal upstream must fail here," + + " in the build, not later on someone's data"); + + Set servedSet = new LinkedHashSet<>(); + for (int packed : served) { + servedSet.add(packed); + } + Set javaSet = new LinkedHashSet<>(); + for (Carving carving : Carving.values()) { + javaSet.add((carving.groups() << 8) | carving.groupBytes()); + } + + for (int packed : servedSet) { + boolean known = javaSet.contains(packed); + c.that("served " + (packed >>> 8) + "x" + (packed & 0xFF) + + " has a Java constant" + (known ? "" : " — a grouping added upstream that" + + " this Java build cannot name"), known); + } + for (Carving carving : Carving.values()) { + int packed = (carving.groups() << 8) | carving.groupBytes(); + boolean present = servedSet.contains(packed); + c.that(carving + " (" + carving.groups() + "x" + carving.groupBytes() + + ") is served" + (present ? "" : " — a Java constant the library does not" + + " know, so passing it would fail at the call"), present); + } + + c.section("every reading covers exactly the 12-byte register"); + for (int packed : served) { + int groups = packed >>> 8; + int groupBytes = packed & 0xFF; + c.that(groups + "x" + groupBytes + " = " + (groups * groupBytes) + " bytes", + groups * groupBytes == 12); + } + + c.section("the served order is STRICTLY descending by group count — the property that stops" + + " a variant reorder upstream from re-mapping the wire"); + for (int w = 1; w < served.length; w++) { + int prev = served[w - 1] >>> 8; + int cur = served[w] >>> 8; + c.that("wire " + (w - 1) + " (" + prev + " groups) > wire " + w + " (" + cur + ")", + prev > cur); + } + + c.section("the round trip through the served table is the identity"); + for (Carving carving : Carving.values()) { + Carving back = Carving.ofWire(carving.wire()); + c.that(carving + " -> wire " + carving.wire() + " -> " + back, back == carving); + } + + c.section("a wire value the table does not name is REJECTED, never aliased onto a" + + " neighbouring reading"); + boolean threw; + try { + Carving.ofWire(served.length); + threw = false; + } catch (IllegalArgumentException expected) { + threw = true; + } + c.that("wire " + served.length + " (one past the served table) throws", threw); + } + + /** Local formatter — {@code CarvingTable} is package-private to the main tree, not the test one. */ + private static final class CarvingTableDescribe { + private CarvingTableDescribe() {} + + static String of(int[] table) { + StringBuilder b = new StringBuilder("["); + for (int w = 0; w < table.length; w++) { + if (w > 0) { + b.append(", "); + } + b.append(w).append("=>").append(table[w] >>> 8).append('x').append(table[w] & 0xFF); + } + return b.append(']').toString(); + } + } +} diff --git a/native/lgj-abi/src/abi.rs b/native/lgj-abi/src/abi.rs index a28ae89..b364071 100644 --- a/native/lgj-abi/src/abi.rs +++ b/native/lgj-abi/src/abi.rs @@ -58,7 +58,18 @@ pub const LGJ_ABI_MAJOR: u32 = 0; /// /// `docs/abi.md` §13). Purely additive: a minor-3 Java loads fine and /// simply cannot call either new symbol. -pub const LGJ_ABI_MINOR: u32 = 7; +/// +/// Minor **8** (2026-08-25): the manifest carries the register groupings as +/// DATA — [`LgjAbiManifest::carving_count`] and [`LgjAbiManifest::carvings`], +/// derived from `CascadeShape::ROTATIONS` (see +/// [`crate::kernels::CARVING_ORDER`]). No new symbol: the manifest already +/// exists so Java can discover the ABI's shape instead of restating it, and the +/// wire encoding is exactly such a shape. This is the FIRST growth of the +/// manifest struct itself, so it is also the change that made Java's load gate +/// require only the base 104-byte prefix rather than the full layout — without +/// that, every future manifest field would be a hard incompatibility with every +/// older artifact. +pub const LGJ_ABI_MINOR: u32 = 8; /// `"LGJ_ABI\0"` read big-endian. /// @@ -366,6 +377,30 @@ pub struct LgjAbiManifest { pub simd_backend_name: [u8; 32], /// `"release"` | `"debug"`, NUL-terminated. pub build_profile: [u8; 16], + /// How many register groupings this build's `ClassView` set defines — the + /// number of valid entries in [`carvings`](Self::carvings). ABI minor 8. + pub carving_count: u32, + /// The register groupings, as DATA: entry `w` is the wire value `w`, packed + /// `(groups << 8) | group_bytes`. Entries past `carving_count` are zero. + /// + /// # Why the manifest and not a symbol + /// + /// This is the one fact that was hand-written three times — a Rust map, a + /// Java enum, and `abi.md`'s table — with nothing to catch drift between + /// them. The manifest already exists to let Java discover the ABI's shape + /// instead of restating it, so serving the groupings here needs no new + /// symbol and no new call: Java reads them at load, alongside the struct + /// sizes it already checks. + /// + /// The Rust side does not hand-write them either — they are derived from + /// the contract's own `CascadeShape::ROTATIONS`, ordered by group count + /// descending. So the contract owns the set, this serves it, and a + /// grouping added upstream propagates to Java with no edit on either side. + /// + /// Fixed at 8 slots: `G·D = 12` admits at most the divisors of 12 that are + /// ≥ 2 on both axes (`6×2`, `4×3`, `3×4`, `2×6`, `12×1`, `1×12`), so 8 is + /// headroom, not a guess. A `count` past the array is a build error below. + pub carvings: [u16; 8], } // --------------------------------------------------------------------------- @@ -382,7 +417,8 @@ const _: () = assert!(align_of::() == 8); // abi.md does not state the manifest's size (it is self-reported, which is the // point), but pinning it still catches an accidental field insertion: // 8 (magic) + 12*4 (u32 fields) + 32 (name) + 16 (profile) = 104, align 8. -const _: () = assert!(size_of::() == 104); +// 104 (minor 7) + 4 (carving_count) + 16 (carvings) = 124, padded to 128. +const _: () = assert!(size_of::() == 128); const _: () = assert!(align_of::() == 8); // A MASK_WORD is 64 row bits in one u64. If this ever stops holding, the whole // bit-order contract below is void. @@ -469,6 +505,27 @@ const fn fixed_cstr(src: &str) -> [u8; N] { out } +/// The `carvings` table, packed from [`crate::kernels::CARVING_ORDER`]. +/// +/// Const, because [`MANIFEST`] is: the whole value of serving the groupings as +/// data is lost if the serving side has to restate them. +const CARVINGS: [u16; 8] = { + let order = crate::kernels::CARVING_ORDER; + // A count past the array is a build error, not a truncation: the manifest + // would otherwise report more entries than it carries. + assert!( + order.len() <= 8, + "carvings table overflow — widen the field" + ); + let mut out = [0u16; 8]; + let mut i = 0; + while i < order.len() { + out[i] = ((order[i].groups() as u16) << 8) | order[i].levels() as u16; + i += 1; + } + out +}; + /// The `'static` the manifest getter hands out. Built entirely from /// `size_of` / `align_of` on the real types — never a literal. pub static MANIFEST: LgjAbiManifest = LgjAbiManifest { @@ -490,6 +547,8 @@ pub static MANIFEST: LgjAbiManifest = LgjAbiManifest { simd_backend: detect_simd_backend().0, simd_backend_name: fixed_cstr::<32>(detect_simd_backend().1), build_profile: fixed_cstr::<16>(BUILD_PROFILE), + carving_count: crate::kernels::carving_count() as u32, + carvings: CARVINGS, }; #[cfg(test)] @@ -535,7 +594,10 @@ mod tests { assert_eq!(size_of::(), 56); assert_eq!(size_of::(), 32); assert_eq!(size_of::(), 24); - assert_eq!(size_of::(), 104); + // The manifest is the one struct that GROWS with the ABI: minor 8 added + // the carving table. Java's load gate requires only the 104-byte base + // prefix, so an older artifact still loads (Layouts.MANIFEST_BASE_BYTES). + assert_eq!(size_of::(), 128); } #[test] diff --git a/native/lgj-abi/src/kernels.rs b/native/lgj-abi/src/kernels.rs index fdba178..00ca360 100644 --- a/native/lgj-abi/src/kernels.rs +++ b/native/lgj-abi/src/kernels.rs @@ -425,29 +425,67 @@ pub fn masked_sum_i32(path: Path, values: &[i32], mask_words: &[u64]) -> i64 { /// across the membrane is this crate's job. pub type Carving = CascadeShape; -/// Wire `u32` -> the contract's grouping. `None` for anything else, so an -/// unknown reading is a rejected call rather than an aliased one. +/// The wire order of the groupings, DERIVED from the contract's own rotation +/// set — not a hand-written list here. /// -/// The mapping is by GROUP COUNT, not by declaration order, so it stays correct -/// if `CascadeShape`'s variants are ever reordered: `0 -> 6` groups, `1 -> 4`, -/// `2 -> 3`. A test pins that correspondence. -pub fn carving_from_wire(v: u32) -> Option { - match v { - 0 => Some(CascadeShape::G6D2), - 1 => Some(CascadeShape::G4D3), - 2 => Some(CascadeShape::G3D4), - _ => None, +/// **The rule is group count, descending.** `6 -> 0`, `4 -> 1`, `3 -> 2` today, +/// and whatever [`CascadeShape::ROTATIONS`] contains tomorrow. Two consequences, +/// both deliberate: +/// +/// - A variant REORDER upstream cannot re-map the wire, because the order is +/// computed from `groups()` rather than from declaration position. +/// - A variant ADDED upstream appears here automatically, in its group-count +/// place, with no edit to this file. +/// +/// This is "data as config" applied to the one fact that was hand-written three +/// times — here, in Java, and in `abi.md`'s table. The contract owns the set, +/// this derives the encoding, and the manifest serves both to Java so it need +/// not restate them. +/// +/// It is a `const` — not a `LazyLock` — because the manifest that SERVES this +/// table is itself const-initialised. A runtime-initialised order could not be +/// reached from there, and the manifest is the whole point. +pub const CARVING_ORDER: [CascadeShape; CascadeShape::ROTATIONS.len()] = { + let mut order = CascadeShape::ROTATIONS; + // Insertion sort, descending by group count. `sort_by_key` is not const, and + // the set is three elements. `G·D = 12` for every shape, so no two share a + // group count and the order is strict. + let mut i = 1; + while i < order.len() { + let mut j = i; + while j > 0 && order[j].groups() > order[j - 1].groups() { + let tmp = order[j - 1]; + order[j - 1] = order[j]; + order[j] = tmp; + j -= 1; + } + i += 1; } + order +}; + +/// How many groupings the wire encoding can name — served in the manifest so +/// Java does not hardcode it. +pub const fn carving_count() -> usize { + CARVING_ORDER.len() +} + +/// Wire `u32` -> the contract's grouping, via [`CARVING_ORDER`]. `None` for +/// anything outside the derived set, so an unknown reading is a rejected call +/// rather than an aliased one. +pub fn carving_from_wire(v: u32) -> Option { + usize::try_from(v) + .ok() + .and_then(|i| CARVING_ORDER.get(i).copied()) } /// The wire value for a grouping — the inverse of [`carving_from_wire`], used /// when the ABI REPORTS a resolved grouping back to the caller. pub fn carving_to_wire(c: Carving) -> u32 { - match c { - CascadeShape::G6D2 => 0, - CascadeShape::G4D3 => 1, - CascadeShape::G3D4 => 2, - } + CARVING_ORDER + .iter() + .position(|&s| s == c) + .expect("every CascadeShape is in ROTATIONS") as u32 } /// Groups per register under this reading — delegates to the contract. @@ -940,6 +978,71 @@ mod tests { } } + /// The manifest must SERVE exactly the derived order — the table Java reads + /// and the table `carving_from_wire` decodes cannot be two answers. + /// + /// This is the falsifier for the data-as-config change: if the packing, the + /// count, or the order in `abi::MANIFEST` were written by hand rather than + /// derived, this catches it drifting from [`CARVING_ORDER`]. It compares the + /// SERVED bytes against the derived set, not one constant against itself. + #[test] + fn the_manifest_serves_exactly_the_derived_carving_order() { + let m = &crate::abi::MANIFEST; + assert_eq!(m.carving_count as usize, CARVING_ORDER.len()); + assert!( + m.carving_count as usize <= m.carvings.len(), + "count past the table" + ); + + for (w, shape) in CARVING_ORDER.iter().enumerate() { + let packed = m.carvings[w]; + assert_eq!( + (packed >> 8) as usize, + groups_of(*shape), + "wire {w}: served groups disagree with {shape:?}" + ); + assert_eq!( + (packed & 0xFF) as usize, + group_bytes_of(*shape), + "wire {w}: served group_bytes disagree with {shape:?}" + ); + // ...and the served entry must decode back to the same shape, so a + // table that is internally consistent but wrongly ORDERED fails too. + assert_eq!(carving_from_wire(w as u32), Some(*shape)); + } + + // Unpopulated slots are zero, so a reader that trusts `carving_count` + // and one that scans for a terminator agree. + for &slot in &m.carvings[m.carving_count as usize..] { + assert_eq!(slot, 0, "slot past carving_count is not zero"); + } + } + + /// The derived order is group count DESCENDING, and strictly so. + /// + /// Without this, a future `CascadeShape` variant could tie on group count + /// and the sort would become order-dependent again — the exact property the + /// derivation exists to remove. `G·D = 12` makes ties impossible today; this + /// pins that it stays that way, and that the sort drops nothing. + #[test] + fn the_derived_order_is_strictly_descending_by_group_count() { + for pair in CARVING_ORDER.windows(2) { + assert!( + pair[0].groups() > pair[1].groups(), + "{:?} then {:?} is not a strict descent", + pair[0], + pair[1] + ); + } + assert_eq!(CARVING_ORDER.len(), CascadeShape::ROTATIONS.len()); + for shape in CascadeShape::ROTATIONS { + assert!( + CARVING_ORDER.contains(&shape), + "{shape:?} dropped by the sort" + ); + } + } + /// A reserved wire value must be REJECTED, never aliased onto a valid /// reading. `from_wire` is the only place that decision is made. #[test]