Skip to content

tables: the map codecs — the C++ reference, the corpus and the gate (#380) - #504

Merged
gafferongames merged 16 commits into
mainfrom
maps-codecs
Sep 4, 2026
Merged

tables: the map codecs — the C++ reference, the corpus and the gate (#380)#504
gafferongames merged 16 commits into
mainfrom
maps-codecs

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

The C++ reference's half of map[K]V (docs/SPEC-TABLES.md §2.8): the storage,
the builder, both wires, the node extent, the cook and the text form, held by a
gate with ten negative controls demonstrated red.

The language half is #487 and the page is #479. The eight ports carry none of
it
— a map is a variable-class construct and the variable class is the
reference's alone — so every port refuses a map-bearing unit by name, and
schema#502 is that gap's home behind PORTING.md's M19 cells.

What a map is here

A LOOKUP over an array of one generated { key, value } ENTRY table held in
ascending key order. No new wire kind, no new framing, no new skip rule. The
holder's record carries SIXTEEN BYTES — a self-relative reference to the entry
array and an int32 count — and the entries are by-value records inside the
HOLDER'S NODE EXTENT, laid after the record's own storage, PRE-ORDER.

1. Storage. TableMap<Entry>: the reference, the live count, padding to
eight — one member, so a port that walked two pieces would account for twelve
bytes where sixteen are written.

2. Region load. LoadMeasure's term is N × sizeof( Entry ) at
alignof( Entry ) AT EVERY DEPTH, summed from the FRAMING alone — N is
framing and not a value, so it reads no field and the caller can refuse a number
it did not expect. Load carves the arrays pre-order out of the node's own
extent. Before an entry's body decodes the reader SCANS its field headers for
the key's id: ascending against the key of the last entry that LANDED, a
duplicate resets the slot it took so last wins WHOLE, a descending key stops the
map and the PARENT reads on, a key past the bound is skipped by its L and
counted clamped, and a key kind the reader does not declare empties the map
for ONE kind_mismatch. LoadBuilder produces exactly Load's report, on
every row — the gate checks the five counters and the flag against each other
for every hostile body it makes.

3. The builder. Insert returns NULL for a key past the bound and for an
arena that cannot carve another segment alike; a duplicate REPLACES, the value
reset and the entry's address unchanged. Find is the linear scan on the
builder and a binary search over the sorted array in the region. Erase marks
one bit. Each is insertion order, live entries only. The sort runs in the four
writing walks, over an array of entry pointers, because sorting the segments
would move entries whose addresses a caller holds. Nothing ever moves: the
gate holds a value handed back by the first of 200 inserts and reads it after
the last.

4. The writer. Ascending, the key first in every entry body, the two
constant ids, an empty map elided by §3's by-value rule.

5. Text form. A plain JSON object keyed by the KEY, integer keys quoted and
read by §16.2's integer rule and nothing else, ascending on the way out. A map
is the other construct the ONE walk cannot walk alone, on the pointer's own
terms (§16.7): three adapters, a stub half in a map-free unit and the real half
in a map-bearing one.

6. Cook and block. The entry array is placed by the layout rule; the slot is
the extent writer's, so a node the walk never reaches keeps an empty one. An
UNREACHED non-empty map slot — a counted array's slot past its live count — is
refused by Cook and by Lock, the same refusal §7.6 gives a pointer there;
the wire is not refused, because a counted array rides its live slots and the
unreached one simply does not ride. A
block form never holds a map — §2.7's absence, already in ir/blocklayout.go.

7. The index. IndexMeasure / Index / IndexFind, caller-owned, built at
load, never stored. Its hash and its load factor are NOT a cross-port contract
and no golden names either; what it is held to is the CONTRACT of the lookup,
and the gate checks it answers exactly what Find answers, present and absent.

11. The projections. The build version and the baseline do not move: no
golden changed, internal/baseline.Version stays 6, and tables/maps joins the
baseline corpus so the ENTRY'S ANONYMOUS KEY is pinned on real schemas —
0a53e00afba279af.d91b.9194 is a nested map's entry, keyed by the holder's wire
id and each map field's on the way in, never by the generated name.

A map-free unit is untouched, to the byte

Every emitter above is gated on the unit declaring a map. go test ./... is
green with no golden re-pin, ./build/schema_test_tables passes, the generic
JSON walk is byte-identical in all 31 generated .cpp files, and
tables-zero-cost still says "value-only tables carry no pointer or map
machinery"
.

The gates

make test gains tables-maps (plain and under ASan/UBSan),
tables-json-map-walk, and tables-maps-negative-controls; tables-big-endian
gains the gate's s390x twin, which reads goldens a little-endian build wrote —
§3's "little-endian, byte-oriented throughout" is a property of the WIRE and
not of the machine, and a map's framing is where that costs something. Each control
patches the GENERATOR through I2's overlay sabotage, regenerates, rebuilds and
requires the gate to go RED on a CHECK — and a sabotage that patches nothing is
itself a failure, so a control cannot rot into a no-op when the emitter it names
moves.

the sabotage the row that meets it red
the writer emits insertion order an instance built OUT OF KEY ORDER FAIL maps_main.cpp:183: memcmp( wire_full, wire_reversed, ... ) == 0
Save emits a DEAD entry an instance that ERASES FAIL maps_main.cpp:201: n = 378, want 424
the reader's ascending check is dropped a SHUFFLED map FAIL maps_main.cpp:501: v.malformed
the duplicate rule is dropped a repeat that ELIDES a field the first set FAIL maps_main.cpp:534: v.decoded[1] = 11, want 0
the key-kind rule decodes anyway a row under a CHANGED KEY KIND FAIL maps_main.cpp:626: mixed_row->entries.size() = 3, want 0
the reader CLAMPS a key a key longer than the reader's bound FAIL maps_main.cpp:556: v.decoded[1] = 11, want 12
the N-against-L fit check is dropped N = 0xFFFFFFFF under a short L FAIL maps_main.cpp:576: RowLoadMeasure( ... ) = 85899345944, want -1
LoadMeasure sums at ONE DEPTH only an instance whose value is itself a map FAIL maps_main.cpp:325: f->tiers.size() = 0, want 2
ToJson writes any order but ascending the round trip's byte compare FAIL maps_main.cpp:709: bomber != NULL && ... && bomber < fighter
Lock writes an UNREACHED non-empty map a counted array's slot past its live count FAIL maps_main.cpp:876: !past.Lock()

The clamp row is the one worth reading twice: the clamped count alone cannot
separate a merged entry from a dropped one, so the DECODED VALUE is the half
that says it — 11 where the rule reads 12.

The corpus

tables/maps is a map of values, a map of pointers whose two keys name ONE
node, a map of maps at three depths, and a SIGNED key where an unsigned compare
would order -3 after 2. The walk-order pin: by_id is a map[K]*T
DECLARED BEFORE the flagship pointer field, so the map reaches the shared node
first and numbers it first; a walk that grouped maps after the pointers would
number them the other way round and map_full.bin says so.
tables/maps/Rows.schema carries the report rows' two roots, which differ only
in the KEY'S KIND, and tables/maps/Depth.schema reaches the other three
by-value edges a map's extent rides under — an array of tables, an enum-keyed
array of them and a UNION ARM (§2.6) — each a different framing the load's
extent scan has to walk.

What this PR does NOT carry

The Go tool half (brief item 8) is not here, and neither are the conformance
manifest rows that depend on it: internal/tablewire, internal/tabletext and
internal/tablecook do not yet decode a map, so there is no second
implementation to hold the reference against and §4.2's differential fuzzer
gains no map rows. The reference's counters are pinned per row by the gate
above, which is what stands in until the oracle lands. schema cook-check's
entry-array containment, alignment, fit and no-overlap checks (brief item 6's
second half) are the tool's too and go with it.

That is the whole of the gap, and it is one PR's worth of work on its own. So
that it is not a silent gap, every table surface the tool has — pack,
unpack, cook, cook-check, uncook — refuses a map-bearing unit BY
NAME
, names the carrier and names the follow-on. Without that refusal the
engine met a kind 14 field over element kind 13 and answered "the framing is
damaged past the point the walk could continue"
, which sends its reader
looking for a corrupt file when the file is fine and the reader is the one that
is short.

Two readings the page leaves open, and what this took

  • Iteration on a map[K]*T. §2.8 says iteration yields "the KEY beside a
    reference to the VALUE"
    and, separately, that the const Find on a
    map[K]*T answers the RESOLVED pointer. Iteration here matches Find — a
    reference to the pointer SLOT would hand a caller a TableRef it has no way
    to resolve from the proxy.
  • A wrong key kind that also desynchronizes the scan. The page orders the
    reader's rules but does not say which wins when a key read under another kind
    leaves the rest of the entry's headers unreadable. This counts the KIND: the
    framing damage follows from the kind, and calling it framing damage would
    report the consequence instead of the cause.
  • An arm is established at selection (§2.6), so a caller that sets a union's
    tag and fills a map inside the arm without resetting it hands the builder a
    map slot out of the overlay's old bytes. That is §2.6's contract meeting
    §2.8's storage; the gate spells the reset at the call site.
  • An entry whose alignment exceeds the arena's — a 128-bit value — is
    refused at compile time by a static_assert naming it, which extends the
    arena's existing node rule (alignof( T ) <= kTableAlign) rather than
    inventing one. The page does not reach this case.

🤖 Generated with Claude Code

gafferongames and others added 16 commits September 4, 2026 16:18
…o wires and the node extent (#380)

The construct's reference half. A map is an array of one generated entry table
held in ascending key order, so nothing here is a new wire rule: what is the
map's own is the SORT on the write side and the key scan, the ascending check
and the duplicate rule on the read side.

- internal/codegen/cpptable/maps.go: the runtime (TableMap's sixteen bytes and
  its binary search, TableKeyOrder, the builder's head and segments, the
  ordered cursor the four writing walks read, the load-side fill, the optional
  side index) and every emitter that hangs off it.
- the NODE EXTENT (§2.8, §6.3): a map's entries are by-value records inside the
  holder's node extent, PRE-ORDER. One walk, three emitters — the extent
  measure, the pack, and the framing scan LoadMeasure answers from. Pack splits
  into the extent and the pointer descent, because a node's extent runs to the
  next directory entry and a pointee laid between two of its arrays would break
  that.
- the READER (§2.8): the key is read before the slot is chosen, by a scan of
  the entry's field headers; ascending against the key of the last entry that
  landed; a duplicate resets the slot it took; a descending key stops the map
  and the parent reads on; a key past the bound is skipped by its L and counted
  clamped; a key kind the reader does not declare empties the map for one
  kind_mismatch.
- the walks (§3.1): a map is a by-value edge at its field's position, its
  entries visited in ascending key order, so a map declared before a pointer
  field reaches a shared node first.
- the COOK's write side (§7.6): the entry array in the node's extent, the slot
  written by the extent writer, and the layout that sizes a node by its value.

A MAP-FREE UNIT IS UNTOUCHED, to the byte: every emitter above is gated on the
unit declaring one, so a pointered unit with no map emits exactly the arena
runtime, Pack and node dispatch it emitted before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…se by name (#380)

target_cpp registers through registerMapCarrier and drops its refuseMaps call.
A map is a VARIABLE-CLASS construct and the variable class is the reference's
alone (docs/SPEC-TABLES.md §2.8, §15), so every port's refusal now names the
carrier and the flag that generates, and refuseMaps loses the no-carrier shape
it carried between the two PRs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e controls (#380)

The corpus unit is what §2.8's rules are held against: a map of values, a map of
pointers whose two keys name ONE node, a map of maps at three depths, a SIGNED
key, and the walk-order pin — the map of *T is DECLARED BEFORE a pointer field,
so a walk that grouped maps after the pointers would number the shared node the
other way round and the pinned wire says so. tables/maps/Rows.schema carries the
report rows' two roots, which differ only in the KEY'S KIND.

test/tables/maps_main.cpp is the gate, plain and under ASan/UBSan: the builder's
five, measure == save over two sorts that never met, one image from two
insertion orders, an erase that costs nothing on any wire, the const form over a
locked region and a loaded one and an opened cook, LoadBuilder producing exactly
Load's report, the optional index answering what Find answers, and every reader
rule on a hand-made body.

Eight of §2.8's named negative controls are Makefile targets, each patching the
GENERATOR through a Go overlay and requiring the gate to go RED — and a sabotage
that patches nothing is itself a failure, so a control cannot rot into a no-op:

  sort       22 failures  the writer emits insertion order
  dead        6 failures  Save emits a dead entry
  ascending   4 failures  the reader's ascending check is dropped
  duplicate   2 failures  the duplicate rule is dropped
  keykind     3 failures  the key-kind rule decodes anyway
  clamp       2 failures  the reader clamps a key instead of dropping its entry
  fit         3 failures  the N-against-L check is dropped
  depth      33 failures  LoadMeasure sums the extent at one depth only

Also here: the key kind is checked BEFORE the framing damage that follows from
it, because a key read under another kind desynchronizes the rest of the scan
and the honest answer to such a body is the KIND; and a variable root whose maps
reach no node no longer emits a node dispatch with nothing to switch on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…de extent (#380)

The register's rule is an issue behind every gap, so the eight ports' cells cite
schema#502 — the map construct's porting home — and the reference's cell names
the gate and its eight negative controls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
§2.8: the build version and the baseline see the entry and neither sees its
NAME. Its line is keyed by the holder's wire id and the map field's, joined by a
dot and chaining through a nested map — never the generated name — so a `was`
rename of the holder or of the map field moves no line and invalidates no cooked
file. tables/maps/tables.baseline is where that key is now pinned on a real
corpus, at three depths and over both key kinds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
)

§2.8's text: a string key is the string, an integer key is its decimal
spelling QUOTED, ToJson writes entries in ASCENDING key order so unpack then
pack is byte-stable, and FromJson reads keys in whatever order the text gives
them. A `*T` value is shared exactly as a pointer field is, so two keys naming
one node carry `&node` in the text as they carry one index on the wire.

A MAP IS THE OTHER CONSTRUCT THE ONE WALK CANNOT WALK ALONE, on the pointer's
own terms (§16.7): its entries live behind a TableMap<Entry> the walk has no
name for and placing one needs the builder's arena. So the walk calls three
adapters and does not define them — a map-free unit defines them as stubs the
classifier makes unreachable, a map-bearing unit defines them in the map half
that follows the walk. The generic walk stays byte-identical in all 31
generated .cpp files, the map half is byte-identical in the two that carry it,
and not one byte of it reaches a map-free unit — `tables-json-map-walk`.

The reader's rules, each held: a repeated key is last-wins and counted
duplicate; an empty object is an empty map; a key longer than N drops its entry
and counts clamped, because a clamped key is a merged entry; an integer key is
read by §16.2's rule and by nothing else, so "2.0" is 2 and "1e3" is 1000; a
value outside the key kind's range is kind_mismatch for that entry, dropped and
never clamped; and a malformed key stops the read where §16.1's rule stops it.

A ninth negative control rides with them: ToJson walking the entries in any
order but ascending turns the round trip's byte compare red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…380)

The text form's map half is emitted only in a unit that declares a map; the
STUBS are what every other unit carries, exactly as it carries the pointer
adapters' stubs, so the ONE generic walk can call three functions it does not
define. A map-free unit's stub half is unreachable — the classifier is a
constant false — and this is the regeneration that says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ntouched (#380)

The CI generated gate found three symbols reaching a map-free unit, and each is
now gated with the rest of the machinery (docs/SPEC-TABLES.md §2.2, §2.8):
TableWorker::AllocRaw, kTableNodeRefused, and NodeStorage's body parameter,
which a root that can name no map-bearing record does not take. A pointered
map-free unit's HEADER is now byte-identical to what it was before the
construct existed, which is what the gate asks.

What does reach every table .cpp is the map adapters' STUB half — the three the
ONE generic walk calls and does not define, exactly as it calls the pointer's
three. The stub is unreachable in a map-free unit, because the classifier there
is a constant false. testdata/golden/tables is re-pinned for it: 18 files, +43
lines each, insertions only, and `make update-goldens` now pins the maps unit's
own sources beside them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…380)

The modernize pin names it, and the loop said nothing the call does not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Its wire goldens were written by a little-endian build, so a codec that reached
for host byte order anywhere in a map's framing — the array's L, its N, an
entry's L, the key's length — would read them wrong and write them back
differently. §3's "little-endian, byte-oriented throughout" is a property of the
WIRE and not of the machine, and the sorted entry array is the same bytes on
both hosts because the ORDER is over the wire's bytes and never over a machine
word. This is where that costs something.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Found by the big-endian twin the previous commit added, which is what it is
for: a cook is written for the build that OPENS it (docs/SPEC-TABLES.md §7), so
a gate that hard-codes little-endian asks a big-endian build to open a file its
magic refuses. The codec crossed the byte order whole — the wire goldens, the
sorted entry array, the load and the text — and the one red was the test's own
hard-coded order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nder (#380)

§2.8 lays "one array per map reachable BY VALUE from the record ... in
depth-first field order", and the reaches that sentence covers are four, each a
DIFFERENT FRAMING the load's extent scan has to walk: a nested table's body, an
array of tables, an enum-keyed body's pairs, and a union arm's payload (§2.6).
The corpus reached only the first. tables/maps/Depth.schema reaches all four in
one root with a field past them, and the gate holds the wire golden, the exact
region (one byte short is refused), the re-save and the tool path.

Held by hand first, and the union arm is where it earned its keep: an ARM IS
ESTABLISHED AT SELECTION, so a caller that sets the tag and fills the map
without resetting the arm hands the builder a map slot out of the overlay's
old bytes. That is §2.6's contract meeting §2.8's storage, and the gate now
spells it at the call site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#380)

§2.8: "An unreached non-empty map slot is refused by Cook and by Lock, the same
refusal §7.6 gives a pointer in that position: a non-null slot in storage the
walk did not reach names entries the region will not hold, so the write answers
false and nothing partial is written."

A COUNTED array's slots past its live count are that storage. The extent walk
reached only the live ones while the cook's extent WRITER walked all N, so a
non-empty map in one wrote its entries into bytes nothing had reserved — a heap
overflow the new `Depth` corpus unit made reachable and ASan named. Both halves
now walk the live count, and a non-empty map past it is the refusal the page
states: the test is the extent itself, because an empty map takes no bytes and
a record whose extent measures ZERO is a record whose every by-value map is
empty.

A tenth negative control holds it, and the WIRE is not refused — a counted
array rides its live slots and the unreached one simply does not ride (§3.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
update-goldens is the ONE deliberate act that re-pins a golden (§7.2), and the
map gate's three wires were not in it — a re-pin would have left them behind
and the next run would have gone red on files nothing regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…amage (#380)

internal/tablewire does not carry the construct yet. Without a refusal at the
surface, the engine meets a kind 14 field over element kind 13, decodes nothing
into a slot it has no shape for, and answers

  the bytes are not a Fleet body — the framing is damaged past the point the
  walk could continue

which sends its reader looking for a corrupt file when the file is fine and the
READER is the one that is short. Every table surface the tool has — pack,
unpack, cook, cook-check and uncook — now refuses by name, names the carrier
(--lang cpp) and names the follow-on (schema#380). A map-free unit reaches the
engines exactly as it did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
§2.8's own wording, and the point of it: a buffer that is the measure and not a
byte more refuses a write that ran over rather than letting it fit by accident,
and one byte short refuses. The gate measured the two against each other but
always wrote into a large buffer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gafferongames
gafferongames merged commit 422b063 into main Sep 4, 2026
20 checks passed
@gafferongames
gafferongames deleted the maps-codecs branch September 4, 2026 07:18
gafferongames added a commit that referenced this pull request Sep 4, 2026
…435)

§2.8 gains the four sentences #504 left open: iteration on `map[K]*T`
yields the resolved pointer as `Find` does; a wrong key kind that also
desynchronizes the scan is that one kind mismatch and nothing more, the
map reading empty; an entry whose alignment exceeds the arena's is
refused at compile time by name; and `Find` on a builder-form map is a
program error, as it is for a TableRef, the form saying which encoding is
in force.

§3's dispatch sentence becomes what it is: the table is resolved once at
open and a body names an id by POSITION, and HOW a reader turns a
reference into a decision is its own choice — nothing on the wire depends
on it and no conformance case can see the difference.

§20.2's `flags` block lands, after the enums and before the unions, and
the projection emits it: each variant with its BIT POSITION, counted from
zero, because the bit is what a stored mask means.

The measured paragraph is re-pinned from THIS tree: 0.98x without the
210 KB blob, 1.80x over the tiny class, 0.99x over the whole corpus, over
69 pinned wires. The DISPATCH nanoseconds are not re-stated — they need a
bench sitting of their own, and a number measured on a laptop under load
is worse than none.

ONE PAGE CONFLICT the flags block exposes, resolved in the direction
§20.2's own reason points: §20.1's table said WHICH flags declaration a
field names carries nothing, and the block makes that false — a field
swapped to a declaration with other variants changes what every stored bit
MEANS. The row now says so, and the control moves from "stays" to "moves".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gafferongames added a commit that referenced this pull request Sep 4, 2026
* tables: the id-table wire's identity, and the engine that writes it (#435)

The wire law first, then the compiler's own engine over it
(docs/SPEC-TABLES.md §3, §3.1, §3.2, §5).

ir/tablewire.go carries the identity and the kinds this form adds: one
hash at one width — fnv1a64, no fold and no rebound — for a field, an
enum variant, a union arm and a table's own name; the reserved
node-table id 0xFFFFFFFFFFFFFFFF; the map entry's two constants; kind 30
for an enum, kind 31 for the escape, kind 32 for a payload-free arm; and
the form byte.

internal/tablewire is the form whole: the form byte read first, the body
as `id reference, kind, payload` terminated by the zero reference,
canonical LEB128 for every length, count, index and reference, the
trailing id table in first-use order located from the end, and the node
table under the reserved id in one field rather than a chain of them.

The page's worked save is a golden of its own: the 120 bytes of §3.1
reproduce byte for byte, and every id on the page — head, palette, Node,
value, next, Palette, id — reproduces from the hash alone.

Nine gates ride with it: the form refusal at 0, 2 and 0xFF, the
reference bound in both directions, the non-canonical reference, the
repeated entry, the stray byte before the table, the unnameable entry
that counts nothing, the key reference of 0, and the keyed body under
the positional kind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the C++ reference writes and reads the id-table wire (#435)

The generated codecs move to the form whole (docs/SPEC-TABLES.md §3,
§3.1, §3.2): the form byte, LEB128 for every length, count, index and id
reference, 64-bit identity, the trailing id table located from the end,
and the three kinds this form adds.

The runtime gains what the form needs and nothing else: `putleb` and
`getleb` with the canonical check, `TableIds` — the writer's table, its
capacity a compile-time fact of the unit's closure so a save still
allocates nothing — `TableIdTable` for the reader, `TableOpen` with its
three verdicts, and `TableBodyEndsEarly` for the stray byte between the
root's terminator and the trailer.

A length cannot be patched in place when its own width varies, so every
length is MEASURED before it rides: the array, keyed and map bodies join
the nested table in computing their length first. The id table is built
by the walk that writes, and an ELIDED field costs no entry — the walk
interns, builds the payload that decides, and truncates when nothing
rides.

An arm header is a field header now: the arm reference, the arm's KIND
byte, its L and its payload. A retyped arm is a kind mismatch where a
retyped field is one, and an arm whose L is not its kind's width is that
arm's own framing damage.

The node table rides in ONE field: a 64-bit L frames a numbering of any
size, so the chunking and its two ceilings are gone.

The descriptors carry uint64_t ids, and the report gains the refusal
verdict — five zero counters and a false flag are what a clean read
prints too.

Held so far by the corpus compiling under -Wall -Wextra -Werror -Wshadow
in every unit, and by 36 of the 46 conformance instances agreeing with
the compiler's own engine byte for byte; the other ten are pinned by
hand-built old-form bytes in test/tables/main.cpp, which the next commit
moves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the projections at sixty-four bits — build version form 3, baseline 7 (#435)

The two texts an id is taken over move with the wire (docs/SPEC-TABLES.md
§18.1, §20.2).

The COOK PROJECTION is form 3: every `field` and `slot` id renders at
sixteen hex digits, an enum field renders under its own kind 30, and a
map entry's anonymous record key is two 64-bit ids. §20.2's worked
example now reproduces character for character, and both of the page's
digests reproduce from it — 0xa9df771f6051391f for the worked unit and
0xc4adaacfe6cb2809 for the same unit with no table.

The TABLES BASELINE is rendering version 7: every `id=` on every line is
sixteen hex digits of fnv1a64, which the page names as a rendering fact
of the same shape as a new judged token, so a file written under the
narrower spelling refuses and names `--update`. The four corpus
baselines are regenerated with that reason recorded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the reserved id and the collision refusals, at sixty-four bits (#435)

ONE ID IS HELD BACK, the node table's 0xFFFFFFFFFFFFFFFF, and no
declared name may produce it — a `was` alias included, because the alias
is what rides (docs/SPEC-TABLES.md §5, §11). Two TABLES of one closure
whose name ids collide are refused naming both, because a node record
says what it is by that id alone (§3.1).

The field, enum-variant and union-arm collision refusals move to
sixty-four bits with the rest of the identity.

At sixty-four bits no input reaches any of them, so the controls PLANT
THE COLLISION BELOW THE HASH through a compiler test hook that returns
the colliding value for one named spelling. Eight rows go red under a
plant and green without one, which is what says each row shows the
refusal rather than the source — the two 16-bit fold pairs the old rows
leaned on (`costarring`/`liquid`, `Agj`/`Atj`) cannot collide at this
width and are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: gofmt the trailer's bounds check

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the reference battery on the form, and the lock closes (#435)

test/tables/main.cpp is rewritten to the grammar. Every hand-built wire
in it was magic bytes of the previous framing; each is now built through
a WireBuilder that speaks §3 — references interned in first-use order,
canonical LEB128 lengths, and the id table the body actually named laid
out behind it — so a forgery says the one thing that is wrong and the
framing around it is the page's.

`seed` is what a repeated-id row needs: the writer's own body minus its
terminator and the table it named, so a second occurrence splices in
under the same references, which is what a repeat IS (§3).

The independent field-id implementation moves to fnv1a64, no fold and no
rebound, and pins the compiler's hash at sixty-four bits. An empty wire
is ten bytes, not two. The framing arithmetic is spelled from the
grammar rather than as `3 + 4 + 5`.

THE ARM EVOLUTION ROWS MOVE WITH THE FORM (§4): an arm header carries the
arm's KIND now, so an int32 arm read as a float32 arm and a scalar arm
read as a string arm are KIND MISMATCHES where they were §4.1's silent
class, and four bytes read as a body is a kind mismatch where it was
framing damage. A2.schema's own comment says what each row is.

And the CROSS-IMPLEMENTATION LOCK closes: all 46 text-carrying instances
agree byte for byte between the reference and the compiler's engine, held
by a Go gate that encodes each instance's text and compares.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the maps battery on the form, and the builder shared (#435)

The hand-built row wires modelled the previous framing with fixed
offsets and u32 patches. A canonical LEB128 length cannot be patched in
place, so the row is built FROM THE GRAMMAR instead: a RowSpec names the
entries and one knob, and every reader control turns that knob rather
than editing bytes behind the writer's back.

The WireBuilder moves to test/tables/wirebuilder.h so the batteries share
one implementation of the form rather than three.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the corpus re-pinned on the form (#435)

Every wire golden, every cooked pair, the block images and reports.txt
are the reference leg's under the id-table form, and `go test ./...` is
green over them — internal/tablepack included, which reads the pinned
wires through the engine.

The three `parts` report rows were bytes fixed by the manifest that
nothing regenerated. They are the reference's now, built from §3's
grammar and pinned by the battery like every other golden, so a form
change moves them with everything else instead of leaving them behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the report format gains the refusal verdict (#435)

Five zero counters and a false flag are what a clean read prints too, so
a FORM BYTE this reader does not carry needs a VERDICT to be visible at
all (docs/SPEC-TABLES.md §3). Every report row is now five counters and
`read` or `refused`: the engine's, the C++ driver's surface, and
FORMAT.md's own statement of the shape.

Three report rows carry it — form 0, form 2 and form 0xFF over a wire
that is otherwise a good v1 Cfg — and the battery holds each to the whole
rule: the verdict refused, no counter moved, no damage reported, and the
value left at its declared defaults. The form byte is read FIRST, so a
file that is both a newer form and damaged is a refusal too.

A TEXT read never refuses, so the json-hostile rows carry `read`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the wire fuzzer on the form, and three reader bugs it found (#435)

The framing model moves to the id-table wire (docs/SPEC-TABLES.md §4.2).
Every number is a variable-width LEB128 now, so a mutation is a SPLICE
rather than a patch: the spot's bytes are replaced and every length
framing it grows or shrinks by the same delta, innermost first, so the
one thing wrong with a mutant is the thing the mutator aimed at.

The passes the page names ride with it: the FORM BYTE at 0, 2 and 0xFF;
the REFERENCE CLASS — every reference to 0, to the entry count (the last
legal slot, which must RESOLVE), to the count plus one and to the
extremes; every number in its NON-MINIMAL spellings, one redundant
continuation byte, then nine, and an eleven-byte form; the ID TABLE —
the entry count off by one each way and at both extremes, an entry's own
bytes flipped, and the same id in two entries; and the four 64-bit
extremes on every length, count and index.

The refusal verdict rides the leg's protocol, so a form byte the leg
does not carry is compared as a verdict rather than folded into damage.

IT FOUND THREE READER BUGS, each a real divergence from the page:

  - `has( (int64_t) n )` on a 64-bit length: 0xFFFFFFFFFFFFFFFF cast to
    int64 is -1, and a negative length looked like room. The fit check
    is unsigned now, as `room( uint64_t )`.
  - `getleb` advanced the cursor on a number it REFUSED, so a body whose
    terminator was non-canonical still ended exactly at its L and the
    damage was stepped over. A refused number leaves the cursor alone.
  - a damaged COUNT dropped an optional array's PRESENCE: the field rode,
    so it is present (§2.3), and only a foreign ELEMENT KIND says the
    payload is not the array's at all.

And two rules the C++ did not carry: the reserved id inside a NESTED
body is malformed (§3.1), and a body carrying the node-table id twice
keeps the LAST occurrence (§3).

91,587 enumerated mutants over 80 seeds, 0 divergences, plain and under
ASan/UBSan. All four negative controls go red on their own check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the gates §3 names, and the instrument for first-use order (#435)

Nine new report rows for the kinds this form added, each built from §3's
grammar and pinned by the reference: kind 31 at a field the reader
CANNOT name (unknown), at a field it CAN (kind mismatch), at an arm, and
as an element kind; kind 32 at a named field. WHERE THE READER MEETS
THEM DECIDES WHICH EVENT IT COUNTS, and each row says which.

Four more for THE ARM'S KIND BYTE: an enum arm and a pointer arm read as
an integer arm, a `bytes` arm read as a `string` arm — kind mismatches
with the union None and the siblings intact — and an arm whose `L` is not
its kind's width, which is that arm's own framing damage.

K1 and K2 are the ENUM-AGAINST-INTEGER pair: the same two field names
with the spellings swapped, so one wire read the other way names both
directions at once. Two kind mismatches each way, both fields at their
declared defaults.

And the ELISION, FIRST-USE AND DISTINCTNESS instrument: over every pinned
instance, the id table must hold exactly the ids the body names, once
each, in the order the body first names them. It says WHICH of the three
rules a drifted wire broke where the byte compare only says one moved.

The fuzzer's locator gained what the instrument needed to be true: a
nested-union arm's payload, an enum or pointer arm's reference, a keyed
triple's element by its own kind, and a BLOB record's body left opaque.
Aiming at those spots found one more divergence, and the leg was right:
a damaged count inside an ARM's `L` is that arm's own framing damage, so
the union reads None (§3) where a FIELD would stay present.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the tool's map half — the wire and the text (#435, #380)

The compiler's engine carries maps now (docs/SPEC-TABLES.md §2.8). A map
rides as kind 14 over element kind 13, an array of the generated
`{ key, value }` entry, so nothing here is a second framing. What is the
map's own is the ORDER and the reader's rules, and both halves have them:

  - the WRITE side sorts into ASCENDING KEY ORDER with no key twice, and
    an empty map elides under §3's by-value rule;
  - the READ side scans each entry's body for the KEY BEFORE choosing its
    slot, keeping the last occurrence §3's repeated-field rule keeps;
  - the KEY KIND is the reader's declaration, never the first entry's, and
    a key under another kind empties the map for ONE kind_mismatch;
  - KEYS NEVER CLAMP: a key past the bound drops its whole entry and counts
    clamped, one per entry;
  - a repeated key is `duplicate` and last wins WHOLE; a descending key
    stops the map with its ascending prefix and flags malformed, and the
    parent reads on past the field's length.

The TEXT form is a plain JSON object keyed by the key, with integer keys
spelled as strings of digits, written in the same ascending order.

Held against the reference: the engine reads the C++ leg's three pinned
map wires — a map, an empty map, and a map of maps with a keyed array and
a union arm holding one — writes them back BYTE FOR BYTE, renders the
text and reads that text back to the same bytes.

The by-name refusal narrows to the COOK surfaces, which is what the tool
still owes, and says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the page's sentences, and the flags block in the projection (#435)

§2.8 gains the four sentences #504 left open: iteration on `map[K]*T`
yields the resolved pointer as `Find` does; a wrong key kind that also
desynchronizes the scan is that one kind mismatch and nothing more, the
map reading empty; an entry whose alignment exceeds the arena's is
refused at compile time by name; and `Find` on a builder-form map is a
program error, as it is for a TableRef, the form saying which encoding is
in force.

§3's dispatch sentence becomes what it is: the table is resolved once at
open and a body names an id by POSITION, and HOW a reader turns a
reference into a decision is its own choice — nothing on the wire depends
on it and no conformance case can see the difference.

§20.2's `flags` block lands, after the enums and before the unions, and
the projection emits it: each variant with its BIT POSITION, counted from
zero, because the bit is what a stored mask means.

The measured paragraph is re-pinned from THIS tree: 0.98x without the
210 KB blob, 1.80x over the tiny class, 0.99x over the whole corpus, over
69 pinned wires. The DISPATCH nanoseconds are not re-stated — they need a
bench sitting of their own, and a number measured on a laptop under load
is worse than none.

ONE PAGE CONFLICT the flags block exposes, resolved in the direction
§20.2's own reason points: §20.1's table said WHICH flags declaration a
field names carries nothing, and the block makes that false — a field
swapped to a declaration with other variants changes what every stored bit
MEANS. The row now says so, and the control moves from "stays" to "moves".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: a field the writer does not write is not an edge (#440)

The C++ authoring walks visited every pointer slot whatever the value
said. §3.1 states otherwise: a pointer under a FALSE GUARD is not visited
and its target takes no index, so a save never writes a record no written
field names — and it is the same walk `Lock` lays a region out in, so the
region holds no such node either. The numbering, PackMeasure and Pack are
one walk here, so gating it gates all three.

G1 is the corpus row and its control: one value with both pointer slots
filled, saved under a false guard and again under a true one — one record
against two, with the region size moving with them. A walk that visited
every slot would write two both times.

The OTHER half of §3.1's sentence, a pointer inside an ABSENT OPTIONAL,
has no declaration to write: `?T` over a variable-length closure is a
named follow-on (§2.3, §15), and its diagnostic names exactly this change
as the precondition — "the authoring walks must gate on the presence
companion before an optional field may hold pointer edges". The walks now
do; lifting the follow-on is a separate decision and is not taken here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the eight ports' wire surfaces go absent, with an issue behind each (#435)

The corpus is pinned in the id-table form and the eight ports write the
previous one, so every surface whose expectation is WIRE BYTES says
ABSENT rather than failing: `wire`, `report`, `json-read`, `json-write`
and `json-hostile`. That is five and not two — json-read writes wire
bytes from a text, json-write reads wire bytes to a text, and
json-hostile packs a hostile text to wire — so all five move together on
the same reasoning.

Each driver's `list` says so where a reader will find it, naming the
port's issue: Go #511, C #512, C# #513, Dart #514, Elixir #515,
JavaScript #516, Java #517, Rust #518. Each issue states what moves and
the rows the port passes to get its surfaces back.

PORTING.md gains M20, the id-table wire, with the reference's cells and
the eight gaps; M19's cell reads the tool's map halves too.

`make conformance` is green again: the reference leg passes every
registered surface, and the ports keep cook, block and the forgery
batteries — none of which the wire moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the per-port conformance controls go dormant with their surfaces (#435)

Seven of the harness's negative controls sabotage a port's answer on a
surface that is now ABSENT — `go`, `go-walk`, `c`, `cs`, `dart`,
`elixir`, `js` and `java` all turn `wire` or `json-read` red, and there
is no longer a cell there to turn. Each says so in one line naming its
port's issue rather than passing silently or failing on nothing, and the
sed scripts that drove them are gone rather than left to rot.

What still proves the harness can go red is untouched: the reference
leg's own control, the block-dump one, the absence controls and the
reference-surface one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tables: the tree brought current with its own compiler, and the gates that follow it (#435)

generated/: nine languages' bench table units carried the previous build
version. The Go one was committed and the other nine were not, so the
`generated` job called the tree stale. The C++ bench table unit also carries
the whole id-table wire now, which is what this branch emits.

testdata/golden/tables/: the byte-identity half of the block zero-cost gate.
22 C++ Table sources move with the emitter this branch changes. 12 C Table
sources move on one line each, the build version constant, whose new values
are the ones testdata/golden/build-version already pins on this branch.

lint: the eight golangci-lint findings and the eight modernize findings the
job names. Three dead functions go (lebSize, tableElemKind, and the
unsuffixed emitEnumElementCheck, whose suffixed form is the one every call
site reaches). Seven untagged switches over a kind take their tag, one
if-else chain over a map key becomes a switch, four errors.As refusal probes
become errors.AsType, and two backward index loops become slices.Backward.

tables-c-big-endian goes dormant, with schema#512 behind it, on the rule the
eight port controls on this branch already follow. The soak's golden gate
compares the C codec against testdata/wire/tables, which is the id-table
form now, and the C port writes the wire's previous form. The same binary
fails the same way on a LITTLE-endian host, so it is not a byte-order
verdict at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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