Repository navigation
Read a container member component in either spelling - #2556
Merged
Merged
Conversation
A set or zset member is spelled into its index component by `hex::encode`, which charges TWO bytes for every member byte. The component is the only term in a container write that scales with member width, which is why set and zset are the two models whose marginal cost per byte is above 1.6x while every other model sits at 1.00x: the index leg takes 100% of the bytes, and inside it the member is written twice -- once as the block key and once, doubled, as the string the block is filed under. This is the READER half of moving that spelling off hex, and it is the half that has to land on its own. `frame_header` turns a key-spelling byte it does not recognise into `Corrupt`, not into an absence, so a binary that cannot read the new spelling does not degrade when it meets one -- it refuses the block. Every reader therefore has to accept the spelling before any writer is allowed to emit it. WHAT IS ADDED `member_component` spells a member as one tag byte followed by the binary-safe name encoding: one byte plus the member's own text for a text member, one byte plus base64 otherwise. `member_from_component` reads a component in EITHER spelling and is the single place that decision is made. The leading tag is load-bearing rather than decorative. `encode_name` returns text unchanged when it can, so an UNTAGGED component for the member `b"abcd"` would be the string `"abcd"` -- which is also valid hex, for two completely different bytes. A component carries no spelling byte of its own, unlike a block, so a reader holding only the string could not tell the two apart and would hand back the wrong member without failing anything. One unconditional tag settles it, because `hex::encode` emits only `[0-9a-f]` and can never lead with the tag. `ElementKeySpelling` gains `Name` (4) and `ScoreThenMemberName` (5) rather than redefining `Hex` (1) and `ScoreThenMember` (2). That byte is written INTO the block, so redefining either would silently reinterpret every block already on disk; new numbers leave them alone and let one store hold both. WHERE IT IS WIRED, AND WHY IT IS NOT INERT Eleven production call sites decode a set or zset member component, and all eleven now go through `member_from_component`: four replay arms in `engine_lifecycle`, two resident lookups in `engine_storage_state`, two derived lookups in `engine`, and three inside `container_blocks` itself -- the selector plus both directions of the spelling pair. The `Hex` and `ScoreThenMember` arms accept both spellings too, which costs nothing, because a hex string cannot begin with the tag. NO WRITER MOVES. `for_kind` still answers `Hex` for a set and `ScoreThenMember` for a zset, and both `hex::encode(&member)` sites in `execute_on_shard` are untouched, so nothing can put byte 4 or 5 into a block yet and no component can lead with the tag. `no_kind_maps_to_a_new_spelling_yet_so_no_writer_can_emit_one` is what holds that, so flipping a writer has to be a deliberate act. LIST IS DELIBERATELY NOT TOUCHED. Its component is sixteen hex digits so that the text sorts in sequence order, and `trim_timestamped_series` evicts by key order, so the spelling there is carrying order rather than just identity. TESTS Eight new tests, each pinned by module path in the hard-gate loop, because this workflow runs no suite that gates: the unfiltered step is `continue-on-error`, so a guard runs only if its name is listed. The existing spelling ladder grows from four entries to six so the round trip covers the new spellings before a writer emits one, and the three counts that floored it at four are now tied to the ladder's own length instead of a literal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eader-accepts-both-spellings
matrixarkai
deleted the
member-component-reader-accepts-both-spellings
branch
November 3, 2026 07:48
matrixarkai
added a commit
that referenced
this pull request
Nov 3, 2026
A container member's index component was `hex::encode(member)` -- two characters of text for every member byte. The component is the term that scales with member width, so that doubling was the member-width RATE of every set and zset write rather than a fixed overhead on one. The writers now file the spelling #2556 taught every reader to accept: one BINARY_TAG then `encode_name`, which is the member's own text when the member is text and base64 when it is not. `ElementKeySpelling::for_kind` answers `Name` and `ScoreThenMemberName`, and because the spelling is derived in that one function, changing it moved all eight set and zset write sites at once. MEASURED. Whole data directory as the denominator, 200 operations into one container, TS_WAL_PREALLOCATE=0, three payload arms, every cell reproducing BIT-IDENTICALLY over three runs. Record compression OFF, where the arithmetic is linear and clean: slope (bytes per member byte) fixed term set 5.0471 -> 3.0467 (-2.0004) 70.0 -> 69.2 zset 5.0311 -> 3.0310 (-2.0001) 88.6 -> 88.9 The rate moved by exactly two and the fixed term did not move. That says the component is written in TWO places and each copy went from two bytes a member byte to one. Segment slopes agree to three decimals at every width, so this is a line and not a fit through noise. Record compression ON, which is the default: 16 bytes 1024 bytes set 148.7 -> 117.7 1872.8 -> 839.7 (-20.8% and -55.2%) zset 166.7 -> 136.7 1897.8 -> 850.4 (-18.0% and -55.2%) The compressed arm is NOT linear in member width: zstd's ratio varies with the member, so a single slope there is a fit rather than a law and the per-width figure is the one to quote. 100% of the delta is in the index leg -- blocks and cache are zero at every cell. THE `envelope per op` COLUMN IS NOT USABLE HERE and is not quoted. At a 1024-byte member it goes NEGATIVE (-193.3 B), because the whole-store bytes fall below the user bytes once the member compresses, and a negative envelope is not a smaller envelope. CONTROLS HELD. string, list and hash are byte-identical before and after in all 36 cells, four widths by three arms. Hash and list are deliberately not moved: a hash field component is its own text at one byte a byte already, and a list component is sixteen characters whatever the element holds, so neither has a doubling to remove. The retargeted guard asserts that too. A BINARY MEMBER GAINS A THIRD, NOT A HALF. `encode_name` base64s anything that is not valid UTF-8, so the component is `2 + 4*ceil(W/3)` there against `1 + W` for text -- two tag bytes, because `member_component` writes one so the two spellings are tellable apart and `encode_name` writes its own so base64 is tellable from pass-through. The cost-per-op fixture is ASCII, so the measured figures above are the TEXT case. Both rates are pinned by a test so neither can be quoted as the other. THE GUARD IS RETARGETED, NOT DELETED. The guard that banned a writer from emitting the new spelling existed to make flipping one a deliberate act, and it did its job. It is now `every_kind_maps_to_the_spelling_its_writers_emit`, asserting what each kind emits including the two that must not move, and the test name CI pins by hand is updated with it. AND THE TAG REMOVES A CONFUSION CLASS, which no byte count shows. A component now begins with a byte that is not an ASCII hex digit, so it can never parse as a sixteen-character element ordinal. The mis-parse test asserted that some names DID collide; it now requires ZERO, with the list control still at the full count so that a zero cannot come from an instrument that stopped looking. Blocks already on disk keep reading: they carry spelling byte 1 or 2, `from_byte` still accepts both, and the `Hex | Name` arm takes either component spelling. A new test builds pre-flip bytes DELIBERATELY rather than through `for_kind`, which would now write post-flip ones and leave the test proving only that today's writer agrees with today's reader. A second new test asserts a post-flip write FRAMES rather than falling back to a bare value -- that fallback reads back correctly, so it would have cost bytes and lost nothing a read-back check sees while making every element written in the window invisible to the load path. Six tests spelled `hex::encode` or `hex::decode` by hand, each a second implementation of the encoding, and each went stale the moment the writers flipped. They now call the engine's own function. Eleven comments that stated a set or zset component IS hex are corrected, since they describe an on-disk format and were left asserting the one this change removes. Co-authored-by: matrixarkai <haiou.jiang@gmail.com> Co-authored-by: Claude Opus 5 <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.
The lever
A set or zset member is spelled into its index component by
hex::encode, which charges two bytes for every member byte. The component is the only term in a container write that scales with member width — and set and zset are the two models whose marginal cost per user byte is above 1.6x (set 1.671x, zset 1.684x on the incompressible arm) while string, hash and feature all sit at 1.00x. 100% of every model's bytes land in the index leg, and inside it the member is written twice: once as the block key, and once, doubled, as the string the block is filed under.container_blocks.rs:47already records the measurement thathex::decode(component)equalled the block bytes on every one of sixty entries. The component does not need to carry the member; it needs to identify it.This is the reader half, and it has to land on its own
frame_headerturns a key-spelling byte it does not recognise intoCorrupt— not into an absence. A binary that cannot read the new spelling does not degrade when it meets one, it refuses the block. So every reader has to accept the spelling before any writer is allowed to emit it.No writer moves in this change.
for_kindstill answersHexfor a set andScoreThenMemberfor a zset, and bothhex::encode(&member)sites inexecute_on_shardare untouched. Nothing can put byte 4 or 5 into a block, and no component can lead with the tag, so the new branch is unreachable on today's data by construction.no_kind_maps_to_a_new_spelling_yet_so_no_writer_can_emit_oneis what holds that.What is added
member_component— one tag byte, then the binary-safe name encoding: 1 byte plus the member's own text for a text member, 1 byte plus base64 otherwise.member_from_component— reads a component in either spelling, and is the single place that decision is made.ElementKeySpelling::Name(4) andScoreThenMemberName(5), rather than redefiningHex(1) andScoreThenMember(2). That byte is written into the block, so redefining either would silently reinterpret every block already on disk. New numbers leave them alone and let one store hold both.The leading tag is load-bearing, not decoration
encode_namereturns text unchanged when it can. So an untagged component for the memberb"abcd"would be the string"abcd"— which is also valid hex, for two completely different bytes. A component carries no spelling byte of its own, unlike a block, so a reader holding only the string could not tell the two apart and would hand back the wrong member without failing anything. One unconditional tag settles it, becausehex::encodeemits only[0-9a-f]and can never lead with the tag.a_member_whose_text_is_valid_hex_is_not_confused_with_the_bytes_that_hex_spellsdrives exactly that case, and the end-to-end selector test usesb"abcd"as its member on purpose.Where it is wired — eleven production call sites, so it is not inert
All eleven sites that decode a set or zset member component now go through
member_from_component:engine/engine_lifecycle.rsengine/engine_storage_state.rsresident_component_address, set and zsetengine.rsengine/container_blocks.rsselect_container_element, plus both directions of the spelling pairThe
HexandScoreThenMemberarms accept both spellings too. That costs nothing and cannot widen them by accident, because a hex string cannot begin with the tag.Settled by reading, not inferred
zset's component order. There is no order question left:
component_from_element_key'sScoreThenMemberarm already dropped the score, and says so — "The resident map (shard.zsets) is the score's only copy now; a reader that wants it reads that map, not this string."zset_componentishex::encode(member)and nothing else. The newScoreThenMemberNamearm renders the member alone for the same reason, so there is nothing to order.A set member is unordered, so set's component spelling is free: the only readers of a set component decode it straight back to the member.
List is deliberately untouched
Its component is sixteen hex digits so that the text sorts in sequence order, and
trim_timestamped_seriesevicts by key order — the spelling there is carrying order, not just identity. It also has the lowest marginal rate of any model at 0.676x, so there is nothing to win. Zero list files in the diff.Tests
Eight new tests, each pinned by module path in the hard-gate loop. That pinning is not ceremony: the only unfiltered step in this workflow is
continue-on-error: true, so it reports and does not gate, and a guard runs only if its name is listed.The existing spelling ladder grows from four entries to six, so the round trip, the frame decode and the two-element selector all cover the new spellings before any writer emits one. The three counts that floored those loops at the literal
4are now tied toEVERY_SPELLING.len(), so the next spelling cannot be added without the floor following it.What is NOT claimed here
No AFTER amplification figure. A reader-only change writes not one byte differently, so it cannot move the rate and none is reported. What is measured at the spelling level is the size of the lever: for a 64-byte text member the component goes from 128 bytes to 65, asserted in
the_new_spelling_charges_one_byte_a_member_byte_where_hex_charges_two. Turning that into a marginal-rate number is the writer flip's job, measured onwhole_store_cost_per_op's three arms with the payload swept rather than sampled.Three tests are red on this branch and red identically on its base commit —
model_map_container_cost::no_nested_model_map_is_a_hashed_table_and_hashes_carries_the_named_container,the_key_a_block_is_filed_under_inverts_exactly::what_the_entry_restating_its_key_costs_the_shipped_containerandround_scale::a_round_walks_the_live_block_set_a_constant_number_of_times. Checked out at the base with nothing of this change present, all three fail the same way; the last one says outright that a round now costs 12 whole-store walks where it measured 13. They are drifted measurement pins on main and not touched by this change.🤖 Generated with Claude Code