0056 a block may be adopted and the invariant is the callers to keep - CyrilB1531/lodestar GitHub Wiki

0056 โ€” A block may be adopted, and the invariant is the caller's to keep

Status: accepted ยท Date: 2026-08-29 ยท Refines: 0053

Context

#474 gave EmbeddingIndex a way to take a whole block of vectors, which 0055 made the sidecar's precondition. Two factories came out of it: EmbeddingIndex.FromBlock, which copies the block once, and EmbeddingIndex.FromOwnedBlock, which does not copy it at all โ€” the caller's array becomes the index's backing store.

The second one is the subject here, because it puts a permanent obligation on a caller and the library has refused exactly that shape once already.

0053 ended on the sentence this decision refines:

the caller who wants pooling can rent and pass the bytes to EmbeddingIndex.Load(ReadOnlyMemory<byte>), which parses in place and never pools.

That is an exposure invariant, and it has two halves. The library reads a caller's memory, which is why the overload exists at all; and the library never takes it โ€” no buffer of the caller's is retained, written to, or handed to a pool. 0054 kept both halves when it reversed the pooling: the rent lives at the Load(Stream) call site and not one line lower, precisely so that the memory overload's exposure stays the caller's own.

FromOwnedBlock holds the mirror image of that invariant. Load's exposure is the library reading a caller's memory for the length of one call, and it ends when the call returns โ€” everything the index keeps afterwards is its own. Adoption runs the other way and does not end: the index reads the caller's array for as long as the index lives, so the obligation moves to the caller and stays there. That is the interesting part, and the part 0053 never had to price. A breach is silent in a way a borrow's cannot be โ€” an array returned to an ArrayPool and re-rented elsewhere becomes this index's embeddings, and the index goes on scoring queries against another renter's bytes without raising anything. With BlockNormalization.Normalize the transfer is visible sooner and just as permanent: the array is normalized in place, so the caller's own values change under it.

Decision

Both factories ship public, and the invariant is the caller's to keep.

FromBlock is what the documentation points a reader at: it costs one pass over the block, asks nothing of the caller, and leaves the array untouched and reusable. FromOwnedBlock is the opt-in beside it, for a caller who has measured that copy and does not want to pay it. Taking it means three things, and the library cannot check any of them: the array is not written to afterwards, it is never returned to a pool, and Normalize rewrites its values in place.

The name carries the whole of it. FromOwnedBlock says the block is owned by the index, not lent to it; a caller who reads the name and reaches for it anyway is choosing the trade rather than walking into it.

Consequences

  • What was refused is the copying factory public with the adopting one an internal seam used only by the load path. It keeps the permanent invariant out of every caller's hands, and NpyFile.Read's freshly allocated array โ€” which nobody else holds โ€” is exactly the case it would serve. It was refused for reach: a caller holding a block from anywhere else (a model's output, a memory-mapped file, a column store) would have no way to avoid a copy the library can see is unnecessary, and would have no way to ask for one.
  • What would change this decision is evidence that the invariant is broken in practice โ€” an issue reporting scores that drift, or a caller found returning an adopted array to a pool. The reversal is to make FromOwnedBlock internal, which is a source-breaking change to a published package and therefore a major version, not a patch.
  • The invariant is asserted, not only written down. EmbeddingIndexBlockTests writes to an adopted array after the index was built and watches the score move, so a future change that quietly copies instead of adopting fails a test rather than passing one.
  • There is no benchmark row for adoption, and none is missing. With AlreadyNormalized โ€” the case the ceiling argument is about, and the one the benchmark passes โ€” it assigns four fields and is constant time whatever the block's size, so its ceiling is the block read that already has a row and a row of its own would publish noise. Normalize is the other case, and it is a pass over the block that FromBlock pays too. bench/README.md ยง12 says the same beside the rows.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ