tables: one traversal for union arms across Number, Pack, Lock, Cook and LoadMeasure (#565) - #578
Merged
Merged
Conversation
tables/arms declares the five shapes schema#565 names: a list of unions whose arm is a pointer to a shared node, a by-value table arm holding a list beside a later container, a union arm that is another union holding a leaf with a list, a bounded array of unions with a populated list in a selected element, and a node and a string blob reachable only through an arm. test/tables/arms_main.cpp crosses each with Measure and Save, LoadMeasure and Load, the tool's path, Lock and a dereference after it, a memcpy relocation, and a cook from the arena and from the region, opened and walked, every reference shown to resolve inside its block before it is followed. Against the current emitters the gate is red on every shape, and the bounded array of unions does not compile at all: its extent writer is emitted with nothing in it, so -Werror refuses the unused parameters. The one emitter change here is the missing <T>Extent of a variable member with no extent of its own, which Lock and the cook layout call in a unit that has an extent elsewhere, and which no corpus had exercised. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…and LoadMeasure (#565) The IR walks a root's closure once (ir.reachableEdges) and both the reachable set and the blob set are read off it: a pointer arm, a list element's arm and an arm of an arm name their nodes like a pointer field, in the numbering's first-visit order. The C++ emitters and the Go tool use the one walk, and the backend copies are gone. The one-union-value arm selection is emitUnionArmWalk, over an edgeExpr that carries the storage under every subject a walk runs on, the value, the pack's twin and the cook's byte address, so a scalar union field, an element of a bounded array and an element of a list take the same switch. A list of unions is an edge of the walk. The extent walk descends a union arm wherever it holds a container, an arm of an arm included, and the cook's extent writer is that walk with the record's address beside the value: its own classification loop is deleted. CookNode holds the extent it wrote to the extent the layout measured before a header is written. The framing scan dispatches from the field's actual shape, one arm header for a union field and a kind 14 body of arm headers for an array or a list of unions, through one <U>WireArmExtent per union with a container below an arm. tables/arms gains Chain, a list of unions whose arm holds a list, and the gate crosses it as it crosses the five shapes. Six negative controls in tools/sabotage, one per shape and one dropping the cook's extent check, each red on a CHECK the clean tree passes. The maps and lists goldens move where the shared walk spells the cook's nested descent, where the arm scan moved into the union's own framing walk, and for the extent check in every CookNode. No list-free, map-free unit moves. The page's §2.6 rationale for refusing a map or a list directly in an arm no longer claims a table wrapper removes tag dependence, which it does not: the restriction stands as a surface rule with what it keeps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…trols re-anchor on the shared reachable walk (#565) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#565) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Under a list or an array of unions the pointer arm's slot loop runs inside the element loop, so the two indexes need two spellings. The slot index is now k, and slot k of element i reads element i. Rack and Tray join the arms corpus with an array-of-pointers arm under a list and under a bounded array, and a negative control reverts the index and goes red on the arms_rack measure.
Every walk descends a table arm as one body, so a bounded or fixed array of tables is refused at the arm by name, pointing at the shape that does work. Whether to admit it is schema#579.
rowan-claude
force-pushed
the
traversal-union-arms
branch
from
September 5, 2026 23:46
3713b22 to
4d5b225
Compare
The memset that establishes a selected arm now takes a void * address. An array-of-pointers arm is the first shape whose storage holds a TableRef, and TableRef has a default member initializer, so it is non-trivial to default-construct while staying trivially copyable. gcc's -Wclass-memaccess reads only the first of those and refused the header on the big-endian leg. The memset stays because the padding inside an arm's storage rides into a cook image and a block row, and value-initialization would leave it alone.
The set of tables a root can place is named before the id map is built from it, because that line is the seam the node-type negative control replaces with the whole unit closure. Folding the two loops into one left the control patching nothing.
rowan-claude
pushed a commit
that referenced
this pull request
Sep 6, 2026
#578 makes ONE traversal serve Number, Pack, Lock, Cook and LoadMeasure, and PointerReachable answers structs off that walk rather than names off the unit. The message form's node dispatch and its Go reader take the same walk, so the two forms number a graph the same way by construction rather than by agreement, and the node type id is the WIRE name's so a table renamed under `was` is still the node its old name numbers. The arms unit it brings has an EXTENT and no MAP, which is the shape that found a latent hole: the message emitters guarded every extent path on `anyMap`, so a unit whose only container is an unbounded array emitted a carve it never set and an extent it never walked. The guard is `anyExtent` throughout now, which is what the file form's own emitters use, and the arms unit compiles as its first witness. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #565.
What this is
One traversal for union arms. The numbering, the pack measure, the pack, the extent measure, the extent pack, the cook's extent writer and the framing scan reach a union's set arm through one arm selection, wherever the union value sits: a scalar field, an element of a bounded array, an element of a list. The static reachable set and the blob set are one IR walk, shared by the C++ emitters and the Go tool.
The corpus, red first
tables/arms(91fd8a4, rebased here as the corpus commit) declares the five shapes the issue names, and this PR adds the cross of two of them,Chain { links []Carry }, a list of unions whose arm is a table holding a list, and the cold read's two shapes,Rack { items []Slots }andTray { entries [..2]Slots }, whereSlots' arm is[..2]*Node.test/tables/arms_main.cppcrosses each with Measure and Save, LoadMeasure and Load into an exact region, the tool's path, Lock and a dereference after it, a memcpy relocation, and a cook from the arena and from the region, opened and walked. Every reference a const form holds is shown to resolve inside its block before it is followed.Against the emitters at the corpus commit the gate does not compile under
-Werror:HandCookExtentandTwigCookExtentare emitted with nothing in them andGateCookLayoutnever reads its context. Compiled with-Wno-unused-parameterand run against the goldens this PR pins, the red lines per shape (54 in all):Ring(a list of unions whose arm is a pointer):measured > 0fails andsaved = 26, want -1, so Measure refuses while Save writes 26 bytes against 106 pinned. The loaded region readsr->items.size() = 0, want 4, and after Lock bothref_inside( base, bytes, first.node, ... )andsecond.nodefail in the locked region and in the relocated copy: the arena offsets survived the Lock. Both cooks refuse.Holder(a table arm holding a list beside a tail): the pinned cook compare,table wire golden arms_holder_cook: 128 bytes written, 128 pinned, andopened != NULL. Same size, different bytes, and no refusal: the cook lost[7]and reported success. Every other crossing is green, which is the issue's point that two equally incomplete walks agree.Nest(an arm that is another union holding a leaf with a list): the loaded region's report ismalformed 1,leaf.items.size() = 0, want 2,ref_inside( ..., leaf.items.elements, ... )fails in the loaded region, the locked region and the relocated copy, and the re-save reads 60 bytes against 81.Hand(a bounded array of unions with a populated list in a selected element): the loaded region's report ismalformed 1,leaf.items.size() = 0, want 3,ref_insidefails, and the re-save reads 64 bytes against 89: LoadMeasure framed the array as one arm header and Load's carve failed on valid wire.Gate(a node and a string blob reachable only through an arm): the loaded region's report isunknown 1for the writer's own record,ref_inside( base, bytes, g->reach.only, ... )fails, the re-save reads 46 bytes against 83, and botharena_cook_bytes > 0andcook_bytes > 0fail because the layout cannot name the type.arms_gate_text(the*stringarm) reads the same way, 46 against 71.After the repair the gate is green over nine values, in the plain build and under
-fsanitize=address,undefined, and the Go tool reads every pinned wire back and writes it again byte for byte, so the two walks number the arms' nodes alike.The cold read's two additions
The cold read of this branch found two things the corpus did not hold, both listed under "Silences found, not decided" below and both closed here. Carrying the first one into the corpus turned up a third, which is the gcc leg's reading of the memset that establishes an arm.
emitPointerSlotsOfspelled the slot indexi, the same name the element loop uses, so under a list or an array of unions the innerishadowed the element index and slotkof elementiread elementk's node.RackandTrayhold the shape and are red before the repair:[arms_rack] measured > 0fails, Save writes 72 bytes against 139 pinned, and the second node of each element is never numbered. The index is nowk. The only other unit the rename reaches isstream, overChunk's andFeed's[..2]*Chunkslots.void *.RackandTrayare the first shapes where the memset that establishes a selected arm runs over storage holding aTableRef, and gcc's-Wclass-memaccessrefused the header on the big-endian leg:TableRefhas a default member initializer, so it is non-trivial to DEFAULT-CONSTRUCT while staying trivially copyable, and the warning reads only the first of those. The memset stays, because the PADDING inside an arm's storage rides into a cook image and a block row and= {}would leave it alone, and the address is cast. Twelve golden lines move, ten inmessagesand two inarms.union U { few [..2]Leaf }over atableLeaf was admitted by the checker and descended by every walk as ONE body, so elements past the first were unreachable. The checker now refuses it at the arm, points at the shape that does work, a table holding the array made the arm, and names schema#579 for whether to admit it.TestArrayOfTablesArmIsRefusedin the break-the-language suite carries both spellings,[..N]Tand[N]T, and keeps a table arm, a scalar-array arm and a[..N]*Tarm green beside them.The repair, per file
ir/table.go:reachableEdgeswalks a root's closure once in the numbering walk's order, calling back at every field line that names a node, and descends every by-value edge there is, a nested table, an array element, a map entry, a list element and a union's arms with nested unions included, and a pointee.PointerReachableandPointerReachableBlobsare read off it.PointerReachableanswers the tables in first-visit order and takes only the root. A pointer arm names its pointee, which is C10.internal/codegen/cpptable/pointers.go: the backend'spointerReachableandreachableBlobsclosures are deleted in favor of the IR's.edgeExprcarries the storage under every subject a walk runs on, the value's path, the pack's twin and the cook's byte address as a base and a constant offset, withmemberandindexfor an arm and an element.emitUnionArmWalkis the one-union-value arm selection factored out ofemitVariableUnionWalk, and a scalar field, a bounded-array element and a list element all take it.edgeOfmakes a list of unions an edge (C1).internal/codegen/cpptable/lists.go:listElementUnion, andemitListEdgevisits a union element throughemitUnionArmWalk.internal/codegen/cpptable/extent.go:unionHasExtentasks an arm of an arm (C8).emitExtentWalktakes the visitor's subjects and hands the three extent emitters anedgeExpr, andemitListElementExtentsdescends a table element or a union element after the whole array.emitCookExtentis that walk with the record's address beside the value, its second classification loop deleted (C2).emitCookNodeholds the extent written to<T>Extentbefore the header.emitWireExtentCasesdispatches from the field's actual shape (C9): one arm header for a union field, a kind 14 body of arm headers for an array, and for a list of unions the elements' arms after the array's own term.emitUnionWireExtentemits<U>WireArmExtentand<U>WireArmsExtentper union with a container below an arm, declared before the members' walks and defined after them.internal/codegen/cpptable/cookwrite.go: the layout uses the IR's reachable set and names its context unused where neither the root nor any node it can name has an extent.internal/tablewire/decodenodes.go,test/conformance/harness/wirefuzz.go: the newPointerReachablesignature, and the two wire-fuzz oracle controls in the Makefile re-anchored on it. The placeable set is named on its own line and says in a comment why: that line is the seamtables-wire-fuzz-node-type-negative-controlreplaces with the whole unit closure.tables/arms/Carry.schema,tables/arms/tables.baseline,test/tables/arms_main.cpp,Makefile:Chainand its wire root in the tool round trip. The baseline moved once, recorded in its history. The gate prints its check count.tools/sabotage/main.go,Makefile: six negative controls, below.docs/SPEC-TABLES.md: the §2.6 bullet, below.Goldens that move
testdata/golden/tables/arms/*: new.maps/DepthTable.h(83 lines): the arm framing scan moved out ofDepthWireExtentintoForceWireArmExtentandForceWireArmsExtent.DepthCookExtentnow takes the shared walk: the nested field's descent is spelled inside the walk's brace, the counted array's live count asi < value.many_count && i < 3, the slots past the count are refused as the pack refuses them, and the union arm'sSquadis cooked, which it was not.SquadCookNodeandDepthCookNodegain the extent check.maps/FleetTable.h(20),maps/RowsTable.h(6),lists/ReportTable.h(9),lists/MigrateTable.h(3),lists/SaveTable.h(13),lists/SharedTable.h(10),lists/HoldersTable.h(21): the extent check in everyCookNodeof a record with an extent, the zero<T>Extentfor a variable member without one (the corpus commit's emitter change, pinned here), and inHoldersthe counted array's cook spelled as the walk spells it.messages/MessagesTable.h(10 lines): thevoid *cast on the arm establishment's memset and nothing else.stream/StreamTable.h(27 lines): the slot-loop index rename and nothing else, inChunkNumber,ChunkPackMeasure,ChunkPackand the same three onFeed, over their[..2]*Chunkslots.No other list-free, map-free unit moves:
examples,pointers,block,blockhome,blobsandscalarsare byte-identical to their pins, and no trackedgenerated/tree carries an arm establishment at all.Negative controls
make tables-arms-negative-controls, in thetools/sabotageand Go overlay pattern, each red on a CHECK the clean tree passes and each held to the line that names its shape:arms-list-union-edge(C1): a list of unions is no edge of the walk. 38 red, first[arms_ring] measured > 0.arms-cook-skips-arm(C2): the cook alone skips a table arm's containers. 20 red,[arms_holder] S::Cook( b, from_arena, ... ): the extent check refuses the cook before a header is written, and so onNest,HandandChain.arms-cook-check-dropped: the same skip with the extent check dropped. 8 red,table wire golden arms_holder_cook: 128 bytes written, 128 pinned, and the control requires that noS::Cookline is red: the cook loses the list and reports success, which is what the check exists to refuse.arms-nested-union-extent(C8): an arm of an arm is not asked. 11 red,[arms_nest] ref_inside( base, bytes, leaf.items.elements, ... )andmalformed 1.arms-array-of-unions-framing(C9): every union field framed as one arm header. 6 red,[arms_hand] a loaded region: the report is not silent (... malformed 1).arms-reachable-arm(C10): a pointer arm names nothing in the reachable walk. 47 red,[arms_gate] ref_inside( base, bytes, g->reach.only, ... )andunknown 1.arms-slot-index-shadows: the arm's slot loop spells its indexiagain, so it shadows the element index. 34 red, first[arms_rack] measured > 0, thensaved = 72, want -1,table wire golden arms_rack: 72 bytes written, 139 pinned,r->items.size() = 0, want 4andref_inside( base, bytes, element.many.value[k], ... )on both live elements. The gate's own-Wshadow -Werrorrefuses the sabotaged header outright, so this one control compiles with-Wno-shadowto reach its CHECK, which is whyarms_negative_controlgrew a flags parameter.The checker refusal's control is the break-the-language suite itself: with the
case f.Array != ir.ArrayNone && !f.Type.Pointer && armNamesTable(f)arm taken back out throughgo test -overlay,TestArrayOfTablesArmIsRefusedgoes red on both spellings,a bounded array of tables: the refusal went missing: []anda fixed array of tables: the refusal went missing: [].The page
docs/SPEC-TABLES.md§2.6, the bullet refusing a map or a list directly in an arm: the rationale said an arm's array would make the extent depend on a discriminant, which no cook could be byte-stable under, and that a table wrapper removes that dependence. A table arm's arrays are placed by the selected tag in the holder's extent too, so the wrapper removes nothing of the kind. The bullet now keeps the restriction as a surface rule with what it actually keeps, one grammar for where a container lives, a field of a record. Whether to admit the constructs in an arm under the deterministic arm rule is not decided here.§2.6 gains a second bullet, refusing an array of tables as an arm and naming schema#579, and §15 gains the follow-on entry that bullet and the diagnostic both point at, beside the three arm refusals that already have one.
Silences found, not decided
union Many { few [..2]Leaf }with a list insideLeaf. CLOSED here: the checker refuses the shape by name and §2.6 and §15 say so, with schema#579 carrying whether to admit it.i. CLOSED here: the slot index isk,RackandTrayhold the shape, andarms-slot-index-shadowsis the control.cook-checkover a list-bearing unit (schema#380) still does not run, so the arms cooks are pinned and walked and not crossed with the tool.Certification
make testlocally on this MacBook Air, after the rebase onto main at 4963be9, runs green through every gate and 52 negative controls red as required, and stops at the conformance harness's Dart leg, which cannot build: this machine's shareddisthas become a symlink to itself, so no pinned SDK under it resolves from any clone. That is a workstation fault, not a tree fault, and the leg it blocks is green on CI. The earlier whole run at c3ab394, before the rebase, read 13:09 to 13:20 with 108 controls.-fsanitize=address,undefined, and every pinned wire round-trips through the Go tool byte for byte. The gate also runs on the big-endian leg, which is where the arm establishment's memset was caught: clang does not have-Wclass-memaccess, so the gcc legs are the only place that reading shows.bench/tables/run.sh --only cpp --rounds 3, from this MacBook Air (arm64) with other builders in sibling clones, each run started under a one-minute load below 2.0. Before, the committedgenerated/bench/tables/cppfrom main's compiler, load 1.88: write 0.746 to 0.760 M msg/s (1528 to 1556 MB/s), round trip 0.253 to 0.258 M msg/s (518 to 529 MB/s). After, regenerated with this branch's compiler, load 1.83: write 0.742 to 0.762 M msg/s (1519 to 1559 MB/s), round trip 0.254 M msg/s (519 to 521 MB/s). Unchanged within the run-to-run spread, as it must be:git diff generated/bench/tables/cppafter the regeneration is empty, because nothing here reaches a fixed table's codec.🤖 Generated with Claude Code