Skip to content

Read a container member component in either spelling - #2556

Merged
matrixarkai merged 2 commits into
mainfrom
member-component-reader-accepts-both-spellings
Nov 3, 2026
Merged

matrixarkai merged 2 commits into
mainfrom
member-component-reader-accepts-both-spellings

Conversation

@matrixarkai

Copy link
Copy Markdown
Owner

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:47 already records the measurement that hex::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_header turns a key-spelling byte it does not recognise into Corrupt — 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_kind still answers Hex for a set and ScoreThenMember for a zset, and both hex::encode(&member) sites in execute_on_shard are 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_one is 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) 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.

The leading tag is load-bearing, not decoration

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. a_member_whose_text_is_valid_hex_is_not_confused_with_the_bytes_that_hex_spells drives exactly that case, and the end-to-end selector test uses b"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:

file sites
engine/engine_lifecycle.rs 4 — the set and zset removal replay arms, and the set and zset install arms
engine/engine_storage_state.rs 2 — resident_component_address, set and zset
engine.rs 2 — the derived set and zset lookups
engine/container_blocks.rs 3 — select_container_element, plus both directions of the spelling pair

The Hex and ScoreThenMember arms 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's ScoreThenMember arm 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_component is hex::encode(member) and nothing else. The new ScoreThenMemberName arm 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_series evicts 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 4 are now tied to EVERY_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 on whole_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_container and round_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

superhaiou and others added 2 commits November 2, 2026 22:14
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>
@matrixarkai
matrixarkai merged commit ddc4143 into main Nov 3, 2026
7 checks passed
@matrixarkai
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>
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.

2 participants