Skip to content

tool: unbounded arrays in the front end, the IR and the tool's wire and text (#531) - #548

Merged
gafferongames merged 9 commits into
mainfrom
impl-unbounded-arrays
Sep 4, 2026
Merged

gafferongames merged 9 commits into
mainfrom
impl-unbounded-arrays

Conversation

@gafferongames

@gafferongames gafferongames commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

SPEC-TABLES.md §2.9 specifies unbounded arrays ahead of their implementation. This lands the front end, the IR and the tool's WIRE and TEXT halves, and it does not land a code generator: every target refuses a unit that declares a []T by name, and §2.9's and USAGE's status lines now say exactly that. The C++ reference's codec is the next PR.

What lands

The front end takes the spelling. []T and []*T reach the IR as ArrayList. The element set is [..N]T's exactly, so []uint128, []bool, []Grade, []*T and []Table are legal and []bits(N), []string(N), []map[K]V and []*bytes are refused on the bounded array's own diagnostic.

Every refusal §11 names. A []T in a type body; a []T or []*T as a union arm, with the table wrapper named; [..]T and [0..]T, each naming []T as the fix; ?[]T; a specified default; a qualification; [..N][]T, [N][]T and [][]T as arrays of arrays; a table holding a [] of itself by value; and the three claimed names <Table><Field>{Add,Each,Erase}. TableList joins the unit-level runtime claim.

The wire. A []T writes the kind 14 body a [..N]T writes — the same element kind, the same count, the same bytes — and an empty one elides under §3's by-value rule. A count above the int32 storage cap is the refusal LoadBuilder answers NULL for, and the slots are grown against the body's own length so a count no body can cover allocates nothing. clamped cannot fire on the count. The node walk reaches a list at its field's position, in index order, so a []*T declared before a pointer field numbers the shared node first.

The text. The JSON array it already is, with every element the text carries read, null a kind_mismatch at a value element and a pointer slot at a []*T, and &node sharing intact.

The projections. The baseline renders array=unbounded with no bound=, which is what makes a bound added warn and a bound removed pass in silence (§18.2). The cook projection renders kind=14 array=unbounded elem=<the element's own storage size> beside the sixteen-byte slot's size=.

Proof, end to end

§2.9's own example packs and unpacks through the tool, &node sharing and null slot included, and the same content under [..8]T packs to byte-identical byteslist_migrates's claim, as a unit test.

Gates and their negative controls

Every gate below was proved by breaking it and watching the named test go red.

gate sabotage what went red
[]T and [..N]T are one wire the walk visits a []*T grouped after the pointer fields TestListWireIsTheBoundedArrayWire: 128 bytes against 165, and they differ
the writer emits index order the elements are reversed before the write TestListRoundTrip: the round trip did not reproduce the wire
a shared node is one node the same TestListRoundTrip: two slots naming one node hold one node
the walk order at a []*T before a pointer the same TestListWalkOrder: both records ride: -1 and 17
a list of TABLES is descended for the edges inside its elements the struct-array case drops the list TestListOfTablesReachesTheirEdges: a node named from two elements is one node
the empty list elides the empty list rides TestListEmptyElides: an empty list rides NO bytes at all: 49
the reader does not clamp the count the list clamps against its declared bound (zero) TestListRoundTrip: report={… Clamped:3 …}
a count the body cannot cover is damage the same TestListCountOverLength: a count the body cannot cover is framing damage
the element-kind rule the decoder decodes a disagreeing kind anyway TestListElementKindMismatch: an element kind that disagrees is one kind_mismatch and no damage
array=unbounded with no bound= the baseline renders it array=bounded bound=0 TestUnboundedRendersWithNoBound, TestAddingABoundWarns
the projection's array line the projection omits the class TestTheUnboundedArrayProjectsAsAnArrayWithNoBound
the sixteen-byte slot the list field's storage is not sixteen bytes the same: does not carry "record Save sizeof=32 alignof=8"
every target refuses a port emits instead of refusing TestEveryTargetRefusesAList: --lang c emitted for a unit declaring an unbounded array
the C++ reference refuses refuseLists dropped from target_cpp.go the same: --lang cpp emitted …
the checker's refusals checkListSpelling returns true TestTableRefusals: eight subtests, expected a diagnostic containing …, got none

What is NOT here, and why

The corpus tables/lists, make tables-list-measure-refusals, the clamp control at 100,000, and every negative control §2.9 names against a generated codec all need a backend that emits one. So does TableRefuseReason and LoadMeasure's reason out-parameter, which §6.5 states are owed as schema#523. None of it is stubbed here: refuseLists names the state plainly and the status lines say the same.

The zero-cost law, and why no benchmark rides here

The owner's 2026-09-05 law binds anything a PR adds that REPORTS — counters, reasons, descriptors — to zero measured cost on the read and write paths of both wires. This PR adds none of them. It adds compile-time refusals, IR facts and handling inside the tool's own engines; it adds no counter, no TableRefuseReason, no descriptor column, and it changes no emitter. The generated CI gate byte-compares the checked-in generated tree and passes, which is what says no generated read or write path moved. The benchmark sitting is owed by the PR that lands the codec.

The #366 matrix

No cell moves. #366's matrix is per LANGUAGE — fixed class, variable class, text form, reflection, block, cook, build version — and no language gained a feature here. The construct reaches that matrix with the C++ codec.

The lock

bench/LOCK carries no prefixes as of #545, and nothing here would have matched one anyway: the construct lives entirely outside internal/codegen/c, internal/codegen/cpp and the generated C and C++ trees. CI's cpp-lock job passes.

Silences and contradictions found on the page

  1. | max on a []T against []fixed(I, F) and []int128. §11 refuses | max on an unbounded array and §2.9 says the element set is [..N]T's exactly, nothing held back. But fixed(I, F) and signed int128 require | min, max (SPEC.md §4.3), so under §11's literal reading []fixed(16,16) is unspellable in both directions: bare it is refused for missing bounds, qualified it is refused for carrying them. [..8]fixed(16,16) | min = -1, max = 1 stays legal. This PR implements §11 literally and refuses the whole qualification vocabulary except was and json, on the map's own precedent. A ruling is owed: either the two element types leave the set by name, or | min/| max are admitted as the ELEMENT's range where they already are on [..N]T and only a count bound is refused.

  2. kind= on the cook projection's array lines. §20's table says an unbounded array's field line carries kind=14, as a map's does. Bounded and fixed arrays render the ELEMENT's kind there today (kind=13, kind=30), which §20.2's own grammar does not contradict. This PR follows the page and renders kind=14 for a list, which leaves []Placement at kind=14 and [..8]Placement at kind=13 on otherwise identical lines. Worth a ruling on whether the bounded rows should move too.

  3. []?T has no spelling. §2.9 and §11 refuse []?T as an array of ?T. The grammar binds ? before the bracket, so the only spelling reachable is ?[]T, which is refused by name. There is nothing else to refuse.

  4. The by-value cycle []Self needs no new refusal. A list's storage is a reference, so its holder is not infinite — but §2.9 states it is refused as a by-value cycle, and the existing composition-cycle walk already refuses it naming the cycle. Nothing was added.

Closes nothing; #531 stays open for the backend.

gafferongames and others added 9 commits September 5, 2026 07:57
…sals (#531)

The `[]T` and `[]*T` spellings reach the IR as ArrayList, the near-miss
spellings `[..]T` and `[0..]T` are refused by name with `[]T` as the fix, and
arrays of arrays are refused where a second bracket stands. The construct's
own refusals land beside the element's: a `type` body, a union arm, `?[]T`, a
specified default, a qualification, and the three claimed names
`<Table><Field>{Add,Each,Erase}`. `TableList` joins the unit-level runtime
claim. Every port refuses a unit that declares one, naming the fields and cpp
as the carrier, and the tool's cook surfaces refuse one on the map's terms.

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

The wire engine writes and reads a `[]T` as the kind 14 body a `[..N]T`
writes: the same element kind, the same count, the same elision of an empty
one. A count above the int32 storage cap is the refusal LoadBuilder answers
NULL for, and the slots are grown against the body's own length so a count no
body can cover allocates nothing. `clamped` cannot fire on the count, because
there is no bound to clamp against.

The text form is the JSON array it already was, with every element the text
carries read. The node walk reaches a list at its field's position, in index
order, so a `[]*T` declared before a pointer field numbers the shared node
first. The baseline renders `array=unbounded` with no `bound=`, which is what
makes a bound added warn and a bound removed pass, and the cook projection
renders `kind=14 array=unbounded elem=` the element's own storage size beside
the sixteen-byte slot's `size=`.

Proved end to end through `pack` and `unpack` over §2.9's own example, and the
same content under `[..8]T` packs to byte-identical bytes.

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

Every claim §2.9 makes about the halves this branch carries is a test with a
negative control behind it: the checker's refusals, the wire's index order and
its shared nodes, the empty list's elision, the element-kind rule, a count the
body cannot cover, the walk order at a `[]*T` declared before a pointer, the
baseline's `array=unbounded` with no `bound=` in both directions, and the cook
projection's sixteen-byte slot.

No code generator carries the construct, so every target refuses a unit that
declares one by name and the tool's cook surfaces refuse on the map's terms.
§2.9's and USAGE's status lines say what is now true: the front end takes the
spelling and holds every refusal, `pack` and `unpack` read and write one, and
the C++ reference lands the codec next.

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

The gate the struct-array walk needed: a `[]Row` whose element holds a `*Leaf`
reaches that node from inside each element, in index order, so two elements
naming one node hold one node and a null slot stays null. The control that
drops the list from the struct-array case goes red on the sharing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
RefuseTableLists, UnitHasList and ListFieldsOf had no caller: the port refusal
lives in compiler/tableslists.go beside the map's, and the two gates the
codegen would use land with the codegen.

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

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

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

golangci-lint named registerListCarrier unused, which it is: no code
generator carries the construct, so the registry the map's file keeps lands
here with the first carrier and the refusal names the target directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gafferongames
gafferongames merged commit c416e64 into main Sep 4, 2026
20 checks passed
@gafferongames
gafferongames deleted the impl-unbounded-arrays branch September 4, 2026 22:02
@gafferongames

Copy link
Copy Markdown
Contributor Author

Rulings on the silences: (1) attributes right of the bar on a []T field qualify the ELEMENT exactly as they do on a [..N]T field, so []fixed(16,16) | min = A, max = B and []int128 | min, max are spelled that way; §11's refusal is of a COUNT bound on a list, and the page is restated to say so in the C++ codec PR. (2) A list renders kind=14 in the cook projection as the page says; a bounded array keeps rendering its element's kind; both stated. (3) []?T unreachable by grammar is stronger than refused and stands. The C++ codec (the TableList runtime, the five element classes, the extent and pack, the cook write, the JSON walk, the §8.1 descriptor migration with the map's emission, the builder's three, the nine-row corpus and goldens) is the next PR for this row, and the #366 cell moves with it.

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