Embedding cache: what invalidates it, and why¶
Shared infrastructure in biotp.embeddings. Embedding 13,858 proteins takes about
100 minutes, so results are computed once and cached; every later question runs on
the cached vectors in seconds. That makes the cache load-bearing rather than an
optimization, and makes a wrong cache hit expensive in the way that matters.
The rule¶
A cache entry is reused only when both halves of its key match:
- The inputs: the exact sequences or texts, including their order and count,
and the residue positions when one is requested. Under the
at_positionreadout the position is part of the input's identity, since the same sequence read at two positions is two different vectors, so the hashed item isf"{sequence}@{position}"rather than the sequence alone. That join is injective:@is outside both the 20-residue alphabet and the decimal digits, so it appears exactly once and splits the item back into the pair that made it, and two distinct (sequence, position) pairs cannot collapse onto one key. - The spec: every parameter of the embedding code that changes the output.
The spec is built by sequence_embedding_spec(model_name, readout) and
text_embedding_spec:
| Field | Why it is keyed |
|---|---|
model_name |
Different checkpoint, different vectors |
impl_version |
Catches changes no other field describes |
max_sequence_length |
Truncation changes long proteins |
repr_layer_policy |
Reading a different layer changes everything |
pooling |
Carries the readout: mean over residues versus a single residue |
dtype |
Output precision |
empty_text (text only) |
Zero vector versus encoding "" |
batch_size is deliberately not keyed. Batching is a throughput knob that
provably does not change the output, verified by the batch-invariance test, so
including it would force needless recomputation. There is a test pinning that
exclusion so nobody "fixes" it later.
Why the spec half exists¶
Embedding is a pure function of its inputs, so hashing the inputs alone is sufficient while the function stays fixed. Edit the transformation and the same inputs legitimately produce different vectors, while an inputs-only key stays identical. The cache then reports a hit and returns the previous implementation's answer, silently.
That was a real defect (issue #4). Changing MAX_SEQUENCE_LENGTH from 100 to 200
produced vectors differing by 0.48, and the cache served the old ones anyway.
Two ways it bites, in increasing severity:
- A benchmark measures nothing. A performance change that should be timed hits the cache instead and reports a near-zero duration.
- Results describe code that is not in the repository. Metrics, figures, and
DECISION_LOG.mdentries all cite an implementation nobody can reproduce from the commit. Every step succeeds and the numbers look plausible.
Cache files explain themselves¶
Each .npz stores the spec that produced it alongside the vectors, so you can
read a cache file and know what made it:
import json, numpy as np
with np.load("data/processed/embeddings/sequence_esm2_35m.npz") as f:
print(json.loads(str(f["spec"])))
A miss also says why, rather than only that it happened:
cache miss, recomputing ... (max_sequence_length: 100 -> 200)
cache miss, recomputing ... (same spec, so the inputs themselves changed)
cache miss, recomputing ... (cache predates spec tracking)
The part that still needs a human¶
impl_version is the residual risk: a change to how residues are pooled is not
visible in any named field, so someone has to bump it. Three safeguards:
test_cache_key_is_stable_for_the_recorded_specpins the exact key for a fixed spec. Any spec change, including a version bump, fails that test, so the change surfaces as something to confirm rather than passing quietly.test_embed_sequences_matches_the_frozen_referencechecks the current code against 24 vectors written by the pre-v2 implementation, so a change that moves the numbers is measured rather than assumed. It needs model weights, so it is markednetworkand the defaultpytestrun skips it; CI opts back in on every pull request that touches the embedding path, and weekly (.github/workflows/embedding-anchor.yml). Until that workflow existed the check ran only when someone typedpytest -m network, which is a poor guard for a failure mode whose defining feature is that nothing looks wrong.CLAUDE.mdcarries the review checklist for diffs touchingembeddings.py.
None of this is airtight, and that is the honest position. CI can tell you the vectors moved; it cannot tell you whether they were supposed to, and so it cannot decide the version bump for you. What the three do together is convert a silent runtime failure into a visible code-review question, and the asymmetry justifies it: an unnecessary bump costs a recompute, a missed one costs the truth of a result.
Version history¶
impl_version |
Change | Vectors moved? |
|---|---|---|
| 1 | The MVP implementation: dataset-order batching, per-sequence pooling | baseline |
| 2 | Length-bucketed batching and on-device masked pooling (issue #3) | yes, at ~1e-6 |
Version 2 is the case the mechanism was built for, and it is worth reading as a
worked example. The pooling rule did not change: it is still the mean over
residue positions, excluding BOS, EOS, and padding, so the named pooling field
reads exactly as before and the key would not have moved on its own. What changed
underneath is that pooling became a masked sum over the batch instead of a slice
per sequence, and batches are now grouped by length, so a given protein rides in
a differently padded batch than it used to. ESM-2 is only padding-invariant to
about 1e-5, so the vectors are close but not bit-identical.
That is precisely the shape of change impl_version exists to catch: no named
field describes it, and every existing cache file would otherwise have kept
serving v1 vectors while the repository described v2 code. It also mattered for
the benchmark that motivated the change, since an "after" run that hit the cache
would have reported a speedup measuring nothing at all.
The v1 vectors are not lost to the argument, incidentally.
tests/data/reference_embeddings.npz holds 24 of them, written before the
rewrite, and test_embed_sequences_matches_the_frozen_reference checks that v2
still reproduces them within float32 tolerance. The version bump says "these are
different"; the anchor says "and here is how much".
A change that needed no bump: the readout parameter¶
Project 2 (issue #11) added a second readout, at_position, which reads one
residue instead of pooling all of them. embed_sequences and cached_embeddings
therefore take readout and positions, neither with a default, so a caller that
has not thought about the readout cannot get one by accident.
This is the opposite case to v2, and it is worth naming as such. A named field
already describes the change: pooling carries the readout, so the two readouts
land in separate cache files under different keys, and nothing stale can be
served. The mean value is byte-identical to the string that predates the
parameter, so every vector cached for Project 1 stays valid and its arms
reproduce bit-for-bit. EMBEDDING_IMPL_VERSION stays at 2, and
test_cache_key_is_stable_for_the_recorded_spec passes unmodified, which is the
evidence for that claim rather than a nuisance to route around.