rust: the self-contained flat word codec — 594% to 444% of generated C/C++ - #183
Conversation
The Rust emitter's serialize functions asked the runtime to place one field at a time. That folds to nothing when the spine lands inlined in a caller whose stream was just built, and costs a memory round trip through the packer per field when it does not — which is the regime every generated Rust call runs in today. The flat form removes the dependence. Every bit offset INSIDE a message is a generation-time constant regardless of where the message starts, so the emitter folds the placement itself: field values into locals, OR'd into 32-bit chunks at literal shifts, one whole chunk per stream call. Reads fuse their bounds checks the same way — one per chunk instead of one per field — the technique the Java, Dart and JS-flat backends already carry. Runs break at align, strings, arrays, branches, nested calls and the fixed-point / compressed-float / 128-bit families, which keep the per-field form. Chunk widths sum to the run's bit count exactly, so the wire is byte-identical: every wire golden unmoved, the cross-language conformance suite green, corpus_id unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The full rust leg and the nine-language quick sweep, one caffeinated sitting on the M2, C/C++ re-run unchanged as the frozen reference (cpp gen within 2.4%, c within 0.7% of the equalized baseline — no alarm). bench/rust/README.md claimed "the generic driver monomorphizes and inlines like the C++ template reference". Measurement refutes the second half: #[inline(always)] on a generated spine is honoured only into the Fn::call shim between driver and spine, and LLVM prices that shim against its caller and refuses it. The README now says what is true and names the diagnostic that measures the cost. inline.go gains the same present-state note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Adversarial review verdict: MERGE, with one required correction now applied to the body. The reviewer reproduced everything on a fresh clone: zero test edits behind the behaviour movements (the hostile-stream error-kind pins unchanged and green; SPEC §5 mandates failure, not kind; the Java/Dart fusion precedent verified against those emitters — Rust's fusion is finer than Java's), zero-diff regen by its own rebuild, lock clean off the raw file list, all 30 per-shape cells and the blended table recomputed exactly, negative control 1.0142x over ten rows, and a code audit clearing precedence/shift-width/chunk-reuse/run-symmetry hazards with write_probe_bits hand-verified bit-for-bit. THE CORRECTION: the diagnostic-build bullet claimed generated read 606 M/s / 111% of generated C++ / read parity. The 606 was the hand-written rt row misattributed; two independent reviewer runs measured gen read ~444 M/s at 0.13% spread (the PR's own later '450 M/s flat' sentence agrees). True figures: 60% write, ~81% read. The cost-model diagnosis survives and is STRENGTHENED — the reviewer also measured plain lto=fat + codegen-units=1 (never measured by the PR; no [profile.release] exists) at ~7% read / nothing write, so the barrier is the inliner cost model, not linkage. Follow-ons filed on #170: name the blended table's statistic (max) beside the per-shape table's median per the doctrine; extend the fuzz compile leg to rustc (the flat form's compilability now rests on the fixed corpus — a wider class of layout-dependent spellings than the old uniform calls). |
Maximal runs of statically-sized pieces are gathered, field values computed into locals and OR'd into word-sized chunk locals at literal shifts, and one whole chunk handed to the stream per call. Reads fuse their bounds checks per chunk and take ONE sticky-error test per run instead of one per field. WriteMixedEntity goes from 14 stream calls to 3. Two deliberate deviations from the Rust template (#183), both Go physics: 1. CHUNKS ARE 64 BITS, not 32. SerializeBits64 places a whole word in one call with one bounds check (its two tryWriteBits are inlined into it), where two SerializeBits are two calls. Wire-identical: SerializeBits64 splits low-dword-then-remainder, which IS the 32-bit chunk order. Chunks of 32 bits or fewer still go through SerializeBits, which inlines. 2. EVERY PIECE IS MASKED TO ITS WIDTH. serialize.go's write path MASKS a too-wide value; the Rust runtime only debug_asserts, so #183 had to REFUSE. Masking is what keeps the Go form observably identical. And one coverage win the Rust template could not have: COMPRESSED FLOATS DO NOT BREAK A RUN. This emitter already folds quantization into generated arithmetic ending in a plain bit write, so quantized fields are ordinary pieces — where Rust's live in the runtime, which is why its compressed-float shapes barely moved. Wire bytes unmoved (testdata/wire clean, corpus_id 6b213fbfa1a03a99). Only Go outputs regenerated; every other language byte-identical. Green: schema_test, _random, _ludicrous, C and C-ludicrous, bench and bench_c (including the 6 bench_mixed refusal vectors), test/go, test/go-ludicrous, and the fuzz leg. Measured, M2, bench_mixed, same sitting, on top of lever 1: write 1.39 -> 2.08 M msg/s (1.50x) round_trip 0.62 -> 1.01 M msg/s (1.63x) Negative control: the hand-written rt rows, same source throughout, held at 1.22/1.20 against a 1.23/1.22 baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maximal runs of statically-sized pieces are gathered, field values computed into locals and OR'd into word-sized chunk locals at literal shifts, and one whole chunk handed to the stream per call. Reads fuse their bounds checks per chunk and take ONE sticky-error test per run instead of one per field. WriteMixedEntity goes from 14 stream calls to 3. Two deliberate deviations from the Rust template (#183), both Go physics: 1. CHUNKS ARE 64 BITS, not 32. SerializeBits64 places a whole word in one call with one bounds check (its two tryWriteBits are inlined into it), where two SerializeBits are two calls. Wire-identical: SerializeBits64 splits low-dword-then-remainder, which IS the 32-bit chunk order. Chunks of 32 bits or fewer still go through SerializeBits, which inlines. 2. EVERY PIECE IS MASKED TO ITS WIDTH. serialize.go's write path MASKS a too-wide value; the Rust runtime only debug_asserts, so #183 had to REFUSE. Masking is what keeps the Go form observably identical. And one coverage win the Rust template could not have: COMPRESSED FLOATS DO NOT BREAK A RUN. This emitter already folds quantization into generated arithmetic ending in a plain bit write, so quantized fields are ordinary pieces — where Rust's live in the runtime, which is why its compressed-float shapes barely moved. Wire bytes unmoved (testdata/wire clean, corpus_id 6b213fbfa1a03a99). Only Go outputs regenerated; every other language byte-identical. Green: schema_test, _random, _ludicrous, C and C-ludicrous, bench and bench_c (including the 6 bench_mixed refusal vectors), test/go, test/go-ludicrous, and the fuzz leg. Measured, M2, bench_mixed, same sitting, on top of lever 1: write 1.39 -> 2.08 M msg/s (1.50x) round_trip 0.62 -> 1.01 M msg/s (1.63x) Negative control: the hand-written rt rows, same source throughout, held at 1.22/1.20 against a 1.23/1.22 baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR #183's Rust template maps one item to exactly one piece, so its fallback (re-emit each of a run's pieces) IS re-emitting each of its items. This emitter's levers made classification 1:N — an unrolled fixed array contributes one piece per element, a flattened nested struct contributes pieces naming ITS fields at a different base expression — and the fallback was inherited verbatim. Three defects followed from that one gap: - `type Pair { values [2]float64 }` emitted the element loop TWICE on both paths: 32 bytes where every other language writes 16. Go-to-Go round trips passed, so nothing went red. - a nested struct split by the run cap emitted `value.C` on the OUTER type — a compile error, or, under a name collision, the silent serialization of the wrong field. - a bare `type Vec2 { x float64; y float64 }` carried an unused "math" import: needsMath was set during SPECULATIVE classification and never cleared when the run fell back. A run now accumulates whole ITEMS as flatGroups and splits only on item boundaries, so the fallback re-emits items against the base each item owns. flatPiece no longer carries an ir.Item at all — a piece is a bit-placement recipe and nothing else, which makes re-emitting one structurally impossible rather than merely avoided. needsMath moves into the float pieces' emit/read closures, which run at emission. Wire is unmoved and generated/ is zero-diff on the corpus: none of the three shapes exists in it. That blind spot is issue #203, addressed next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PR carried a before/after table and a 0.9977x negative control with NO committed CSV, and #199 retired the shapes those numbers were taken over, so they could not be re-run on main. These two files are that evidence, re-taken on the shape that survives (bench_mixed, family gen) and committed before AND after, which is #183's precedent. One sitting, back to back, nothing changed between the halves but generated/bench/go — main's Go emitter output for the before half, this branch's for the after. Every other leg is the in-sitting control. go write 1.33 -> 2.71 M msg/s (2.04x), round_trip 0.59 -> 1.44 (2.45x) headline 602% -> 251% of generated C++ on run.sh's own statistic (2.40x) controls geomean 1.0050x over 14 rows (cpp, c, rust, cs, js, java, dart; min 0.9854, max 1.0376) — unchanged code, and it did not move The go rows carry 0.24% and 0.31% spread here against 10.44% in the branch's earlier committed quick CSV, which is why the max and median statistics now agree (251% and 249%) where that CSV had them 20 points apart (255% and 275%). Named rather than smoothed: elixir's after-half rows are REFUSED under §2.3 (spread 66% and 213%) — a background spike hit the leg that ran last. It is a control row, not the subject, and it is reported here instead of being dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maximal runs of statically-sized pieces are gathered, field values computed into locals and OR'd into word-sized chunk locals at literal shifts, and one whole chunk handed to the stream per call. Reads fuse their bounds checks per chunk and take ONE sticky-error test per run instead of one per field. WriteMixedEntity goes from 14 stream calls to 3. Two deliberate deviations from the Rust template (#183), both Go physics: 1. CHUNKS ARE 64 BITS, not 32. SerializeBits64 places a whole word in one call with one bounds check (its two tryWriteBits are inlined into it), where two SerializeBits are two calls. Wire-identical: SerializeBits64 splits low-dword-then-remainder, which IS the 32-bit chunk order. Chunks of 32 bits or fewer still go through SerializeBits, which inlines. 2. EVERY PIECE IS MASKED TO ITS WIDTH. serialize.go's write path MASKS a too-wide value; the Rust runtime only debug_asserts, so #183 had to REFUSE. Masking is what keeps the Go form observably identical. And one coverage win the Rust template could not have: COMPRESSED FLOATS DO NOT BREAK A RUN. This emitter already folds quantization into generated arithmetic ending in a plain bit write, so quantized fields are ordinary pieces — where Rust's live in the runtime, which is why its compressed-float shapes barely moved. Wire bytes unmoved (testdata/wire clean, corpus_id 6b213fbfa1a03a99). Only Go outputs regenerated; every other language byte-identical. Green: schema_test, _random, _ludicrous, C and C-ludicrous, bench and bench_c (including the 6 bench_mixed refusal vectors), test/go, test/go-ludicrous, and the fuzz leg. Measured, M2, bench_mixed, same sitting, on top of lever 1: write 1.39 -> 2.08 M msg/s (1.50x) round_trip 0.62 -> 1.01 M msg/s (1.63x) Negative control: the hand-written rt rows, same source throughout, held at 1.22/1.20 against a 1.23/1.22 baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR #183's Rust template maps one item to exactly one piece, so its fallback (re-emit each of a run's pieces) IS re-emitting each of its items. This emitter's levers made classification 1:N — an unrolled fixed array contributes one piece per element, a flattened nested struct contributes pieces naming ITS fields at a different base expression — and the fallback was inherited verbatim. Three defects followed from that one gap: - `type Pair { values [2]float64 }` emitted the element loop TWICE on both paths: 32 bytes where every other language writes 16. Go-to-Go round trips passed, so nothing went red. - a nested struct split by the run cap emitted `value.C` on the OUTER type — a compile error, or, under a name collision, the silent serialization of the wrong field. - a bare `type Vec2 { x float64; y float64 }` carried an unused "math" import: needsMath was set during SPECULATIVE classification and never cleared when the run fell back. A run now accumulates whole ITEMS as flatGroups and splits only on item boundaries, so the fallback re-emits items against the base each item owns. flatPiece no longer carries an ir.Item at all — a piece is a bit-placement recipe and nothing else, which makes re-emitting one structurally impossible rather than merely avoided. needsMath moves into the float pieces' emit/read closures, which run at emission. Wire is unmoved and generated/ is zero-diff on the corpus: none of the three shapes exists in it. That blind spot is issue #203, addressed next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PR carried a before/after table and a 0.9977x negative control with NO committed CSV, and #199 retired the shapes those numbers were taken over, so they could not be re-run on main. These two files are that evidence, re-taken on the shape that survives (bench_mixed, family gen) and committed before AND after, which is #183's precedent. One sitting, back to back, nothing changed between the halves but generated/bench/go — main's Go emitter output for the before half, this branch's for the after. Every other leg is the in-sitting control. go write 1.33 -> 2.71 M msg/s (2.04x), round_trip 0.59 -> 1.44 (2.45x) headline 602% -> 251% of generated C++ on run.sh's own statistic (2.40x) controls geomean 1.0050x over 14 rows (cpp, c, rust, cs, js, java, dart; min 0.9854, max 1.0376) — unchanged code, and it did not move The go rows carry 0.24% and 0.31% spread here against 10.44% in the branch's earlier committed quick CSV, which is why the max and median statistics now agree (251% and 249%) where that CSV had them 20 points apart (255% and 275%). Named rather than smoothed: elixir's after-half rows are REFUSED under §2.3 (spread 66% and 213%) — a background spike hit the leg that ran last. It is a control row, not the subject, and it is reported here instead of being dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PR carried a before/after table and a 0.9977x negative control with NO committed CSV, and #199 retired the shapes those numbers were taken over, so they could not be re-run on main. These two files are that evidence, re-taken on the shape that survives — bench_mixed, family gen — and committed before AND after, which is #183's precedent. One sitting, back to back, on the tree as it stands after #204 rewrote the runners. Nothing changed between the halves but generated/bench/go: main's Go emitter output for the before half, this branch's for the after. go write 1.35 -> 2.67 M msg/s (1.98x), round_trip 0.60 -> 1.41 (2.36x) headline 583% -> 248% of generated C++ on run.sh's own statistic (2.35x) controls geomean 0.9940x over 16 rows — every other leg, unchanged code, compiled identically in both halves (min 0.9426, max 1.0339) corpus_id is 6b213fbfa1a03a99 in every row of both files: the runner hashes the goldens it loads, and Degenerate.schema's golden is not one of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* go lever 1: fold the ranged-integer READ path to generation-time bit counts The write side already folded its bounds; reads still called the runtime's SerializeInt/SerializeInt64, which derive the bit count from min/max on EVERY call (a BitsRequired per field) and carry the bounds as arguments. Both are compile-time constants of the call site. Folded, reads drop onto SerializeBits — whose wrapper the Go compiler inlines, where the ranged entry points are 168/328 cost units against an 80-unit budget and never inline. The headroom refusal moves into generated code with the same error, ErrValueOutOfRange, and is elided where the range fills its bit width. Wire bytes unmoved; corpus_id 6b213fbfa1a03a99 unchanged. Measured, M2, bench_mixed, same sitting: write 1.37 -> 1.39 M msg/s (+1.5%), round_trip 0.61 -> 0.62 (+2.6%). The small size is the finding: the cost is the CALL COUNT, not the call's arguments. That is what lever 2, the flat word codec, attacks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * go lever 2: the self-contained flat word codec Maximal runs of statically-sized pieces are gathered, field values computed into locals and OR'd into word-sized chunk locals at literal shifts, and one whole chunk handed to the stream per call. Reads fuse their bounds checks per chunk and take ONE sticky-error test per run instead of one per field. WriteMixedEntity goes from 14 stream calls to 3. Two deliberate deviations from the Rust template (#183), both Go physics: 1. CHUNKS ARE 64 BITS, not 32. SerializeBits64 places a whole word in one call with one bounds check (its two tryWriteBits are inlined into it), where two SerializeBits are two calls. Wire-identical: SerializeBits64 splits low-dword-then-remainder, which IS the 32-bit chunk order. Chunks of 32 bits or fewer still go through SerializeBits, which inlines. 2. EVERY PIECE IS MASKED TO ITS WIDTH. serialize.go's write path MASKS a too-wide value; the Rust runtime only debug_asserts, so #183 had to REFUSE. Masking is what keeps the Go form observably identical. And one coverage win the Rust template could not have: COMPRESSED FLOATS DO NOT BREAK A RUN. This emitter already folds quantization into generated arithmetic ending in a plain bit write, so quantized fields are ordinary pieces — where Rust's live in the runtime, which is why its compressed-float shapes barely moved. Wire bytes unmoved (testdata/wire clean, corpus_id 6b213fbfa1a03a99). Only Go outputs regenerated; every other language byte-identical. Green: schema_test, _random, _ludicrous, C and C-ludicrous, bench and bench_c (including the 6 bench_mixed refusal vectors), test/go, test/go-ludicrous, and the fuzz leg. Measured, M2, bench_mixed, same sitting, on top of lever 1: write 1.39 -> 2.08 M msg/s (1.50x) round_trip 0.62 -> 1.01 M msg/s (1.63x) Negative control: the hand-written rt rows, same source throughout, held at 1.22/1.20 against a 1.23/1.22 baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * go lever 3: place nested struct fields inline instead of calling per element With the per-field calls gone, the profile's next item was the CALL boundary itself: bench_mixed carries up to 80 MixedStat and 8 MixedEntity elements, and each paid a function call plus its own sticky-error load. WriteMixedStat was 12.4% of samples for 18 bits of work, and at cost 134 against Go's fixed 80-unit budget it will never inline itself. An array whose element is a struct that flattens whole now has its element body placed directly in the loop. The element's own Write/Read functions are still emitted — the public surface is unchanged — they are simply no longer the path the generated caller takes. Measured, M2, bench_mixed, same sitting, on top of lever 2: write 2.08 -> 2.27 M msg/s (+9%) round_trip 1.01 -> 1.11 M msg/s (+10%) Control rows held at 1.17-1.20 write / 1.18-1.21 read against the 1.22/1.20 baseline. Wire unmoved, conformance green. Cumulative over the three levers: write 1.37 -> 2.27 (1.66x), round_trip 0.61 -> 1.11 (1.82x). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * go levers 4 and 5: nested struct FIELDS and small fixed arrays join the run Two coverage extensions on the same mechanism, both cheap once a run can be built against an arbitrary base expression: 4. A nested struct field that flattens whole contributes its fields to the PARENT's run, so they pack into the parent's chunks rather than paying a call and starting fresh chunks of their own. 5. A small fixed array of scalars is a static run of bound*width bits like any other, so it unrolls into the enclosing run instead of looping one stream call per element. Capped at 128 bits so generated code stays proportional to the schema, and byte-aligned [N]uint8 keeps the bulk-copy path, which is faster than either form. Measured, M2, bench_mixed, same sitting, on top of lever 3: write 2.27 -> 2.32 M msg/s (+2%) round_trip 1.11 -> 1.16 M msg/s (+4.5%) Control rows 1.20/1.21 against the 1.22/1.20 baseline. Wire unmoved, conformance green. Cumulative over five levers: write 1.37 -> 2.32 (1.69x), round_trip 0.61 -> 1.16 (1.90x), blended 1.83x. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * go: flatten only where it REDUCES the stream call count The full per-shape leg caught two rows going the wrong way: rigidbody_moving and rigidbody_at_rest both lost 9% on write. Mechanism, found in the emitted code — RigidBody is all float64, so every piece is exactly one whole chunk. The run packed into as many chunks as it had fields, removing no call, while ADDING a materialized local per field and an address-taken chunk local, where the per-field form had simply passed the struct field's own address. The policy is now the honest one: flatten when ceil(bits/chunk) is fewer calls than the run has bit-carrying pieces, and otherwise leave the per-field form alone. That makes the transform non-regressive on call count by construction rather than by luck. bench_mixed is unaffected (write 2.35, round_trip 1.16 — its runs all reduce calls); the rigidbody shapes return to the per-field form. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * go lever 6: pack consecutive array elements into shared chunks The profile after the first five levers put the generated code's own packing arithmetic at 15% of samples and the runtime's bit placement at 30% — the per-field calls were gone, but bench_mixed still spent most of its calls on eighty 18-bit stat elements, each carrying 18 of a possible 64 bits. An array of a struct that flattens whole is now emitted as a K-at-a-time loop over a run spanning K elements, followed by a remainder loop. K is chosen as the element count that minimises chunks-per-element within the run cap and an eight-element unroll cap; for the 18-bit stat element that is 7, and eighty elements fall from 80 stream calls to 25. Measured, M2, bench_mixed, same sitting, on top of lever 5: write 2.34 -> 2.66 M msg/s (+14%) round_trip 1.15 -> 1.40 M msg/s (+21%) Cumulative over six levers, against current main in one sitting: write 1.38 -> 2.66 M msg/s (1.93x) round_trip 0.61 -> 1.40 M msg/s (2.30x) blended 1182 -> 545 ns/msg (2.17x) Named behaviour change, the same class the run form already carries: a group's refusals all run before the group packs, so a refused write has emitted fewer bits than before. Which values are refused, and with which error, is unchanged. Wire bytes unmoved, conformance green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * go lever 7: raise the run cap to its measured peak, and scope the grouped loop Two things, found together when the cap was moved. THE BUG: the grouped array form declares its index outside the loop, so two grouped arrays in one function collided on i. It only surfaced once the cap let a second array group, but it was latent from lever 6. The grouped loop now carries its own block, as the runs already do. THE CAP: measured on the corpus shape rather than inherited from the Rust template's 256. bench_mixed write / round_trip, M msg/s, one sitting: 256 -> 2.66 / 1.40 384 -> 2.78 / 1.49 <- peak 512 -> 2.75 / 1.46 1024 -> 2.62 / 1.43 The curve peaks and falls: past the peak a run holds more field values live than the register file has room for and they spill, which is the cost the cap exists to bound. 384 also lets the 135-bit entity element group two at a time. Cumulative over seven levers, against current main in one sitting: write 1.38 -> 2.78 M msg/s (2.01x) round_trip 0.61 -> 1.49 M msg/s (2.44x) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * bench: the gocodec quick sweep — go at 255% of generated C/C++ Six legs measured in one sitting (java, dart and elixir report ABSENT: this fresh clone has no pinned dist/ toolchains and the box has no JRE). The locked c/cpp reference reproduces at 100/104%, which is the control on the sitting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * go: range over int in the group loop (modernize lint) Emitter output unchanged — regenerated with no diff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * go: re-derive the invariant the fallback rests on — groups, not pieces PR #183's Rust template maps one item to exactly one piece, so its fallback (re-emit each of a run's pieces) IS re-emitting each of its items. This emitter's levers made classification 1:N — an unrolled fixed array contributes one piece per element, a flattened nested struct contributes pieces naming ITS fields at a different base expression — and the fallback was inherited verbatim. Three defects followed from that one gap: - `type Pair { values [2]float64 }` emitted the element loop TWICE on both paths: 32 bytes where every other language writes 16. Go-to-Go round trips passed, so nothing went red. - a nested struct split by the run cap emitted `value.C` on the OUTER type — a compile error, or, under a name collision, the silent serialization of the wrong field. - a bare `type Vec2 { x float64; y float64 }` carried an unused "math" import: needsMath was set during SPECULATIVE classification and never cleared when the run fell back. A run now accumulates whole ITEMS as flatGroups and splits only on item boundaries, so the fallback re-emits items against the base each item owns. flatPiece no longer carries an ir.Item at all — a piece is a bit-placement recipe and nothing else, which makes re-emitting one structurally impossible rather than merely avoided. needsMath moves into the float pieces' emit/read closures, which run at emission. Wire is unmoved and generated/ is zero-diff on the corpus: none of the three shapes exists in it. That blind spot is issue #203, addressed next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * corpus: the degenerate arrangements, C++-pinned in all nine languages (#203) Three emitter defects — one of them silent wire corruption — reproduced on one-line schemas, and the entire conformance corpus caught none of them. It grew from real game shapes, and a real shape always carries enough neighbouring fields to mask the arrangements a run/chunk emitter keys on. The corpus's realism is precisely what made it blind. examples/Degenerate.schema carries twelve shapes that are deliberately unrealistic: a fixed scalar array as essentially the whole message ([2]float64, [2]uint64, [2]int64, [1]T, [N]T landing on a chunk boundary, an array plus a trailing field, two arrays back to back); a bare two-float unit with no other math consumer in the file; and a nested struct as the ONLY field, as the FIRST field, and straddling the Go emitter's run cap. They are pinned as one wire golden, testdata/wire/degenerate.bin, and every one of the nine legs is held to it: the stream legs (C++, C, Go, Rust, C#, JS) write the twelve into one stream, and the whole-message legs (Dart, Java, Elixir) concatenate their twelve buffers. That equality is why every type in the file is a whole number of bytes wide — the file's header says so, because it is load bearing. internal/codegen/golang states the same properties directly against maxRunBits, so re-tuning that constant cannot quietly retire the coverage. Verified red first: on the pre-fix emitter all four assertions fail, and generated/go/Degenerate.go does not compile ("math" imported and not used; value.C undefined on TrioStraddle). The new file caught a SECOND emitter defect on sight, the same class as the Go one: the Dart backend declared all four conversion-scratch views whenever any was needed, so a file converting only float64 carried an unreferenced _u32 and `dart analyze` refused it. The views are now emitted per conversion. Existing wire goldens are unmoved; the corpus protocol id moves, as adding a file must, and is re-pinned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * bench/go: state the --cpuprofile flag in the usage line and refuse instead of panicking The Go leg is not untouched: the flat word codec's conviction — ~86% of generated-codec time inside the runtime's per-field bit calls — came from this flag. It stays, because the next round will want it and a profiler kept in a scratch fork of the leg is a profiler that rots. It is an iteration instrument: it changes nothing about what is measured, and no timed row is taken under it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test/c: dirty the degenerate read targets before reading them gcc's -Wmaybe-uninitialized (CI, -Werror) cannot see that a read function stores every field, and clang does not raise it, so the new block built clean here and refused there. The rest of the leg already dirties its read targets — a read that skips a field must be caught — so this block does the same, with the same 0xEF fill. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * bench: the paired same-sitting go A/B, with its controls, committed The PR carried a before/after table and a 0.9977x negative control with NO committed CSV, and #199 retired the shapes those numbers were taken over, so they could not be re-run on main. These two files are that evidence, re-taken on the shape that survives — bench_mixed, family gen — and committed before AND after, which is #183's precedent. One sitting, back to back, on the tree as it stands after #204 rewrote the runners. Nothing changed between the halves but generated/bench/go: main's Go emitter output for the before half, this branch's for the after. go write 1.35 -> 2.67 M msg/s (1.98x), round_trip 0.60 -> 1.41 (2.36x) headline 583% -> 248% of generated C++ on run.sh's own statistic (2.35x) controls geomean 0.9940x over 16 rows — every other leg, unchanged code, compiled identically in both halves (min 0.9426, max 1.0339) corpus_id is 6b213fbfa1a03a99 in every row of both files: the runner hashes the goldens it loads, and Degenerate.schema's golden is not one of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * go: bind the run once in flush, and say why an oversized group takes the per-field form Comment and shape only — generated output is byte-identical. flush() built the run twice, once to ask worthFlattening and once to emit it; the oversized-group branch had no note saying no classifier produces one today. The orphaned flatFieldPiece doc above flatArrayElemPiece now sits on the function it describes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The third port of the template Rust (#183) and Go (#198) already carry: field values into locals, OR'd into 64-bit chunk words at literal shifts, one packer step per chunk instead of one per field. WHY IT PAYS HERE IS NOT WHY IT PAID IN GO. #198's finding was that Go's per-field entry points sit past the compiler's inlining budget, so every field cost a real call and ~86% of the codec's time was inside them. C# has no such wall: the JitDisasm audit found ZERO direct calls into serialize.cs from any generated method — every entry point inlines. What C# pays instead is what those inlined bodies DO. BitWriter's packer step masks, shifts, ORs into the scratch word, tests `newScratchBits >= 64`, conditionally stores a qword and recovers the spill: a data-dependent branch and a conditional store PER FIELD. MixedEntity paid fourteen of them for 135 bits. It now pays three. THREE INVARIANTS, RE-DERIVED RATHER THAN INHERITED — #198's review record is why they are spelled out in flat.go's header rather than assumed: 1. 64-bit chunks are wire-identical because serialize.cs's SerializeBits64 splits low-dword-first-then-remainder, which IS the 32-bit chunk order. 2. Every piece is masked to its width, because BitWriter.WriteBitsUnchecked TRUNCATES a too-wide value rather than refusing it — the flat form must truncate identically or a wide value corrupts its neighbours in the chunk instead of losing its own high bits. This is the invariant the Go port lost by inheriting Rust's fallback; here it is enforced at the one place a piece's value expression is built. 3. Write refusals come first, in declaration order — same values refused, same non-latching false. What moves is how many bits reached the stream before the refusal, and a refused write leaves a partial message either way. A run breaks at align, strings/bytes, arrays, branches, nested struct and union calls, and the float, fixed-point, compressed-float and 128-bit families. A run that would not REDUCE the packer-step count is not flattened. A run that does not pay falls back ITEM BY ITEM — the item rides inside every piece for exactly that reason, because the item is the only unit the per-field path can re-emit. THE READ HALF IS NOT IN THIS COMMIT, and the blocker is named rather than worked around: folding a ranged read replaces SerializeInt with a generated comparison, and serialize.cs's ranged read LATCHES ValueOutOfRange where a generated guard returns false without latching. cs publishes checks=always and test/cs pins that latch, so the read fold waits on a public refusal-latch on ReadStream/ReadBatch — a serialize.cs change, its own PR. The write path has no such dependence: its folded range refusals already return false without latching. Measured ceiling for the read half, from a hand-written flat prototype of the two hot cores: round_trip 1.08 -> 1.55 M msg/s, a 42% cut in read time. MEASURED, against the previous commit, same box, median of 3 sittings: bench_mixed write 2.82 -> 3.30 M msg/s (+17%) bench_mixed round_trip 1.08 -> 1.18 M msg/s (+9%) and against main: bench_mixed write 2.10 -> 3.30 M msg/s (+57%) bench_mixed round_trip 0.97 -> 1.18 M msg/s (+22%) flatMaxRunBits is Go's 384 carried unmeasured: swept at 128/256/512 the C# numbers do not move, because this corpus's longest run is 135 bits and no cap in that range binds it. Said out loud in the file rather than presented as a C# measurement. Wire bytes unmoved: full `make test` green across nine backends, the degenerate corpus included, and the bench's golden + per-variant round-trip gates pass at corpus_id 6b213fbfa1a03a99. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…word codec (write 1.59x, round_trip 1.60x; 361% -> 219% of generated C++) (#208) * C# emitter: union batch cores, and a scoped batch per composition site The C# backend was the only one never audited for generation quality, and the profile of the bench shape found the cost in one place: composition. MEASURED FIRST (DOTNET_JitDisasm + DOTNET_JitDisasmSummary, FullOpts, tiering off, on generated/bench/cs/Bench.cs). Everything the runtime offers already inlines — zero direct calls into serialize.cs from any generated method. What did not inline was the generated code's own composition: the entity array (8 elements) and the stat array (80 elements) each reached their element type through its STREAM entry, and each of those entries opened a batch and closed it again. 88 BeginBatch captures and 88 End restores per 438-byte message, plus 88 real calls the JIT will not inline through, on a message where those two arrays carry 72% of the wire bits. They reached the stream entry because BenchMixed reaches a union, and the batch plan excluded any type that reached one — a union's arm dispatch had no core to compose with. That exclusion was landed as land-simple with the note "a follow-on if a profile ever convicts the plain path". It has. TWO CHANGES, one law each. 1. Unions carry batch cores. A union's arm dispatch is a switch over calls, and inside a core those calls go core-to-core by ref like any other composition — nothing about the inline-only rule ever stood in the way, only the missing emitter half. densityUnion weighs the tag plus the heaviest arm; the core closure walks arms as well as struct fields. 2. A composition site inside a stream body opens a SCOPED batch — one capture and one restore for the whole site, an array's entire loop included. Only the composition call rides inside the scope (an array's count site stays on the stream ahead of it), so the scope has exactly one way to fail and End always runs before the refusal leaves the function. AND ONE MEASURED REFUSAL, recorded because it is a result. The obvious third change — let the whole message body run on one batch now that the union no longer blocks it — is SLOWER, and materially: bench_mixed write 2.10 -> 2.04 M msg/s and round_trip 0.97 -> 0.76 M msg/s against the same baseline the two changes above improve. The JIT did inline the 12.7 KB core (IL size 23 at the entry), so this is not the address-exposure law firing; it is the delegated sites. A body with any bulk, 128-bit or fixed-point site must Sync the batch down and Recapture it around that site, and between them it hands the register allocator a ref struct that has to survive the whole body. batchEntry states it: density is necessary, b == 0 is the rest. MEASURED, this branch vs main, same box, bench --quick, median of 3 sittings (iteration instrument, not certification): bench_mixed write 2.10 -> 2.82 M msg/s (+34%) bench_mixed round_trip 0.97 -> 1.08 M msg/s (+11%) Wire bytes unmoved: full `make test` green across nine backends, the degenerate corpus included, and the bench's own golden + per-variant round-trip gates pass at corpus_id 6b213fbfa1a03a99. Checks semantics are untouched — the same inputs are refused with the same errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * C# emitter: the flat word codec, write half The third port of the template Rust (#183) and Go (#198) already carry: field values into locals, OR'd into 64-bit chunk words at literal shifts, one packer step per chunk instead of one per field. WHY IT PAYS HERE IS NOT WHY IT PAID IN GO. #198's finding was that Go's per-field entry points sit past the compiler's inlining budget, so every field cost a real call and ~86% of the codec's time was inside them. C# has no such wall: the JitDisasm audit found ZERO direct calls into serialize.cs from any generated method — every entry point inlines. What C# pays instead is what those inlined bodies DO. BitWriter's packer step masks, shifts, ORs into the scratch word, tests `newScratchBits >= 64`, conditionally stores a qword and recovers the spill: a data-dependent branch and a conditional store PER FIELD. MixedEntity paid fourteen of them for 135 bits. It now pays three. THREE INVARIANTS, RE-DERIVED RATHER THAN INHERITED — #198's review record is why they are spelled out in flat.go's header rather than assumed: 1. 64-bit chunks are wire-identical because serialize.cs's SerializeBits64 splits low-dword-first-then-remainder, which IS the 32-bit chunk order. 2. Every piece is masked to its width, because BitWriter.WriteBitsUnchecked TRUNCATES a too-wide value rather than refusing it — the flat form must truncate identically or a wide value corrupts its neighbours in the chunk instead of losing its own high bits. This is the invariant the Go port lost by inheriting Rust's fallback; here it is enforced at the one place a piece's value expression is built. 3. Write refusals come first, in declaration order — same values refused, same non-latching false. What moves is how many bits reached the stream before the refusal, and a refused write leaves a partial message either way. A run breaks at align, strings/bytes, arrays, branches, nested struct and union calls, and the float, fixed-point, compressed-float and 128-bit families. A run that would not REDUCE the packer-step count is not flattened. A run that does not pay falls back ITEM BY ITEM — the item rides inside every piece for exactly that reason, because the item is the only unit the per-field path can re-emit. THE READ HALF IS NOT IN THIS COMMIT, and the blocker is named rather than worked around: folding a ranged read replaces SerializeInt with a generated comparison, and serialize.cs's ranged read LATCHES ValueOutOfRange where a generated guard returns false without latching. cs publishes checks=always and test/cs pins that latch, so the read fold waits on a public refusal-latch on ReadStream/ReadBatch — a serialize.cs change, its own PR. The write path has no such dependence: its folded range refusals already return false without latching. Measured ceiling for the read half, from a hand-written flat prototype of the two hot cores: round_trip 1.08 -> 1.55 M msg/s, a 42% cut in read time. MEASURED, against the previous commit, same box, median of 3 sittings: bench_mixed write 2.82 -> 3.30 M msg/s (+17%) bench_mixed round_trip 1.08 -> 1.18 M msg/s (+9%) and against main: bench_mixed write 2.10 -> 3.30 M msg/s (+57%) bench_mixed round_trip 0.97 -> 1.18 M msg/s (+22%) flatMaxRunBits is Go's 384 carried unmeasured: swept at 128/256/512 the C# numbers do not move, because this corpus's longest run is 135 bits and no cap in that range binds it. Said out loud in the file rather than presented as a C# measurement. Wire bytes unmoved: full `make test` green across nine backends, the degenerate corpus included, and the bench's golden + per-variant round-trip gates pass at corpus_id 6b213fbfa1a03a99. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * C# emitter: the flat word codec, read half The read half of the same form: chunks read first, ONE sticky-error test for the whole run, then every value unpacked, validated and stored with literal shifts and literal masks. Where the per-field form paid a bounds check and an error test per field, MixedEntity now pays one of each per chunk. WHAT IT WILL NOT ABSORB, AND WHY. A ranged read whose refusal can actually fire goes through serialize.cs's SerializeInt/SerializeInt64, which LATCH SerializeError.ValueOutOfRange; a generated comparison returns false without latching. cs publishes checks=always and test/cs pins that latch, so a ranged piece joins a run ONLY where its check is VACUOUS — max - min is exactly 2^bits - 1, so no value the bits can hold is out of range — or where this emitter's own read path ALREADY refuses without latching (the full-range unsigned bits64 path, which has carried a generated refusal all along). Everything else closes the run and keeps the runtime call. Same inputs refused, same errors, on every path. AND THAT RESTRICTION COSTS ALMOST NOTHING — which retires a lever rather than deferring it. The hand-written prototype that folded EVERY ranged read of the two hot cores, latch semantics and all, measured round_trip 1.55 M msg/s. This commit, which leaves four of MixedEntity's fourteen fields on the runtime call because their range checks can fire, measures 1.54. The public refusal-latch on ReadStream/ReadBatch that the full fold would need is worth about 1% here, and is NOT worth a serialize.cs API addition on this evidence. Recorded as ranked-and-refused, not as a follow-on. TWO CONSEQUENCES OF FUSION, named in the file rather than found later: - A stream BOTH truncated inside a run AND carrying an out-of-range value before the truncation now surfaces the stream's overflow error where the per-field form surfaced the range refusal. Both refuse the packet; the set of ACCEPTED streams is unchanged, which is the property that matters at the trust boundary. Rust, Go, Java, Dart and JS-flat already carry it. - A failed run leaves the destination object untouched where the per-field form left the fields before the failure updated. Both are partial states a failed read gives no contract over (SPEC §5). NEGATIVE CONTROL, run rather than assumed: with flatMaskLit forced to emit no mask — invariant 2 deliberately broken — `make test` produces 65 failure lines across the backends. The gate that proves this form does see it. MEASURED, against the previous commit, same box, median of 3 sittings: bench_mixed write 3.30 -> 3.30 M msg/s (unchanged, as expected) bench_mixed round_trip 1.18 -> 1.54 M msg/s (+30%) and against main: bench_mixed write 2.10 -> 3.30 M msg/s (+57%) bench_mixed round_trip 0.97 -> 1.54 M msg/s (+59%) Wire bytes unmoved: full `make test` green across nine backends, the degenerate corpus included, test/cs and test/cs-ludicrous (which pin the corrupt-wire refusals) among them, and the bench's golden + per-variant round-trip gates pass at corpus_id 6b213fbfa1a03a99. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * bench/run.sh: the schema commit line carries its branch Every runtime line in the preamble already records `<sha> (<branch>)`; the schema commit line recorded a bare sha. Two failures that closes, both of which this repo has already had: a sweep that silently measured the wrong tree and a CSV that could not have said so, and a published result whose sha stopped resolving the moment the branch was rebased. The branch name survives the rebase. One line in run.sh (it reuses commit_of, which the runtime lines already use) and its law-home in BENCH-STANDARD.md §3.5, beside the provenance clause the same class of defect earned in August. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * bench/results: the cs-word-codec before/after pair, both halves, two sittings Both halves committed, --only cs, taken back to back in the same window, twice. Preambles carry the schema commit AND its branch on the after half; the before half runs main's own run.sh, which predates that line, so the file says so in its own note. bench_mixed, cs, family gen, checks=always (M msg/s): path before max after max before median after median write 2,104,563 3,342,814 2,103,454 3,334,630 1.59x round_trip 992,155 1,594,245 990,865 1,586,844 1.60x Against the quiet-box reference sweep of the same day and the same corpus_id (bench/results/2026-09-01-arm64-macbook.csv, cpp round_trip median 3,467,551; cpp write median 7,287,687), on the round_trip statistic the published ledger reads at: cs before 350% of generated C++ (the published ledger row: 361%) cs after 219% and on write, 347% -> 219%. The `bits` family rows in this pair are NOT attributable to the change and the files say so where a reader will meet them: BitsBench.cs exercises serialize.cs's raw BitWriter/BitReader and no generated code at all, yet its rows still move 11% across the halves at sub-3% spread. That can only be process-level — JIT method placement and cache state after a differently shaped bench_mixed pass in the same process. An open question, not a result. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * flat.go: the bare-integer width test stops pretending to be a switch staticcheck QF1002 on CI: a two-arm `switch { case w == 64: default: }` is an if. Generated output is byte-identical — the regenerated tree is unchanged and `make test` stays green across nine backends. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Campaign item 1 of the laggard round (#170): the Rust emitter learns the self-contained flat word-codec form. Wire bytes never move;
checks=alwaysis preserved exactly; nothing underbench/LOCK's prefixes is touched.The move
Blended nine-language quick table, one caffeinated sitting, M2, generated subject:
Before column: the equalized sitting (
bench/results/2026-08-31-equalized-quick-*). After:bench/results/2026-08-31-rustcodec-quick-*. The frozen reference re-ran unchanged — cpp gen within 2.4% write / 0.5% read, c within 0.2% / 0.7% — and every leg I did not touch landed within 2% of its equalized number, so the sitting is comparable and the movement is the emitter's. Rust is now the second-fastest generated leg.Full rust leg, per shape (family
gen, median msgs/s, M2):The hand-written
rt/bitscontrol rows — the same source before and after — moved 1.014x across ten rows. That is the negative control on the sitting: the generated rows moved because the generated code changed.What the form is
Every bit offset inside a message is a generation-time constant no matter where the message starts. The old form did not use that: it handed the runtime one field at a time, which folds to nothing unless the spine lands inlined in a caller whose stream was just constructed. Entered with an unknown bit position — the regime every generated Rust call actually runs in — each
serialize_bitsreloads the packer's scratch, shifts by a runtime amount, tests a runtime carry and stores the state back. An eleven-field write paid eleven memory round trips through the stream.internal/codegen/rust/flat.gofolds the placement itself: field values into locals, OR'd into 32-bit chunk locals at literal shifts, one whole chunk per stream call. Reads fuse their bounds checks the same way — one per chunk instead of one per field — the technique the Java, Dart and JS-flat backends already carry.Runs break at align, string/bytes, arrays, branches, nested struct and union calls, and the fixed-point / compressed-float / 128-bit families, whose value arithmetic lives in the runtime. Those keep the per-field form and start a fresh run after themselves. Runs also close at 256 bits so a hundred-field message does not hold every field value live at once. Chunk widths sum to the run's bit count exactly — the last chunk carries
B mod 32— so the flat form touches precisely the bits the per-field form did.The public surface is unchanged: same function names, same
(&mut WriteStream, &T) -> Resultsignatures, same types.Wire and semantics
git status testdata/wireclean across the change).make testgreen: the full cross-language conformance suite,test/rustandtest/rust-ludicrousincluded, plus the hostile-stream cases that pin specific error kinds (nonzero reserved →Validation, truncation → the stream's own error, out-of-range count →Error::Stream).corpus_idunchanged in every bench run:457b96dfc5a0ffcdquick,bc776c004485e408full.checks=alwayspreserved: the same values are refused, with the same error, on every path. The checks-mode ruling stays the owner's.Negative control, run and pasted:
Two behaviours move, both named rather than smoothed:
Overflowwhere the per-field form surfaced the range refusal. Both refuse the packet; the set of accepted streams is unchanged, which is the property that matters at the trust boundary. This is the same consequence the Java/Dart/JS-flat fusion already carries.Where the remaining 4.4x lives — and it is not the codec
Honest account: 444% is not C-tier, and I can name the whole residue.
The generated Rust spine is never inlined into the timed loop, and the emitter cannot fix that.
#[inline(always)]on the spine is honoured — into theFn::callshim standing between the bench driver and the spine. LLVM then prices that shim against its caller and refuses it. clang honoursalways_inlineunconditionally, so the C and C++ legs never see this regime. Three measurements pin it:#[inline(always)]in this harness movesbench_mixedby less than half a percent (97.16/102.97 vs 97.11/103.12). The attribute never reaches the loop.RUSTFLAGS="-C llvm-args=--inline-threshold=5000"— a diagnostic, not a shipped flag — moves the generated rows a further 2.28x geomean and the hand-writtenrtrows 4.54x.bench_mixedgoes to 367 M/s write and ~444 M/s read ;rigidbody_movingread 10.0x;probe_headerwrite 1034 M/s. Against generated C++'s 610/547 on the same sitting, that is 60% and ~81% — read approaches within ~1.23x and write is within 1.7x. (CORRECTED at review: this bullet originally claimed 606 M/s read and "read reaches parity" — the 606 was the hand-writtenrtrow misattributed as the generated row; the reviewer's two independent runs measured gen read 444.5/437.8 M/s at 0.13% spread, and this PR's own later "450 M/s flat" sentence agrees. Read does NOT reach parity under the diagnostic.)So the ceiling this form approaches, once the inlining discipline matches the reference legs, is near-C-tier on reads (~1.2x). The residue is LLVM's inliner cost model at the harness boundary, not language physics — and, per the reviewer's own measurement, not linkage in the LTO sense: plain idiomatic
lto = "fat"+codegen-units = 1(never measured by this PR; the repo has no[profile.release]at all) buys ~7% on read and nothing on write, nowhere near the threshold diagnostic's ~3x. That strengthens the cost-model diagnosis and retires the "linkage asymmetry" framing this paragraph first used. I did not equalize it here: it changes the measured leg's build or driver mid-round for one language only, and that is the owner's call, not a worker's.bench/rust/README.mdclaimed the driver "inlines like the C++ template reference"; that claim is now corrected to what is measured, with the diagnostic named.The flat form is what makes the out-of-line regime cheap, which is why it lands regardless of how the inlining item is ruled — and it wins in the inlined regime too on reads (
bench_mixedread 450 M/s flat vs 573 M/s per-field is the one row where the per-field form is ahead once inlined; write is 346 vs 300 the other way).Aside, not shipped, as invited by the brief: stripping only the write-side range refusals — what a
contractchecks mode would do — buys +13.2% onbench_mixedwrite (126.1 → 142.7 M/s), reads unaffected. That is roughly 6% blended: 444% → ~420%. It does not change the picture, and the mode staysalways.Coverage gaps, named as follow-ons
rigidbody_*moved only 1.01–1.05x andexamples128's generated Rust is byte-identical before and after. Both are pure functions of the value plus generation-time constants; folding their arithmetic into the run is the obvious next pass, gated on a differential against the runtime's own quantization.N * wbits; unrolling small ones into the enclosing run is a bounded, measurable next step (probearray1.18x/1.05x is the row that would move).Land and expand: each is a separate measured pass, not a reason to hold this one.
🤖 Generated with Claude Code