fix(protspace): own every retained cache by what produced it - #404
Conversation
Automated reviewDoes it solve #338? Partially — forcing Found 4 issues:
protspace/apps/protspace/src/protspace/data/processors/pipeline.py Lines 396 to 413 in 3d9d670
protspace/apps/protspace/notebooks/ProtSpace_Preparation.ipynb Lines 600 to 609 in 3d9d670
protspace/apps/protspace/tests/test_issue_338_reproduction.py Lines 15 to 30 in 3d9d670 🤖 Generated with Claude Code |
|
Independent triage against current head
All current CI checks are green, but none of the added coverage exercises the upstream cache-identity cases in items 2-3. No thread is being resolved by this response. |
|
Implemented the verified review items in
Verification: focused regressions |
| " print(\"Select at least one embedder.\")\n", | ||
| " return\n", | ||
| " fasta_path = Path(inp[\"path\"])\n", | ||
| " cache_dir = _input_cache_dir(cache_root, fasta_path)\n", |
There was a problem hiding this comment.
[P1] Scope the embedding cache by backend
This content-owned directory is later paired with only {emb_name}.h5, although the notebook lets the user switch between Local and Biocentral. Both production backends resume by skipping IDs already present in that H5 (local.py:283-285, biocentral.py:150-167). On this head I seeded this exact cache layout with a Local-produced vector, then called embed_fasta(..., backend="biocentral"); it returned the unchanged Local vector without invoking Biocentral. A backend change can therefore silently generate projections and bundles from the previously selected backend. Please include the backend (and any embedding-affecting configuration) in cache ownership, or validate producer metadata before reuse, and add a backend-switch regression.
There was a problem hiding this comment.
Implemented in 82b6dbb2. The existing input-content directory now uses backend/model-owned H5 names ({backend}-{embedder}.h5), so Local and Biocentral cannot satisfy each other’s resume-by-ID cache while identical input/backend/model runs still select the same file. Regression coverage exercises both the Local→Biocentral switch (Biocentral is invoked and its vector returned) and same-backend reuse. Verification: focused cache/backend/query suite 21 passed; full non-slow Python suite 801 passed, 6 deselected; Ruff, notebook parse, strict OpenSpec, and pnpm precommit all passed.
| " return\n", | ||
| " step_html.value = \"<b>Step 1/6: Downloading FASTA...</b>\"\n", | ||
| " fasta_cache = cache_dir / \"sequences.fasta\"\n", | ||
| " fasta_cache = _query_fasta_cache_path(cache_root, inp[\"query\"])\n", |
There was a problem hiding this comment.
[P2] Publish query FASTA caches atomically
The new query-addressed path is still accepted solely when it exists and has nonzero size. query_uniprot writes save_to directly, so interruption during gzip extraction can leave a truncated-but-nonempty FASTA; the next Generate extracts whatever headers are present and permanently treats that subset as the full query result. I reproduced acceptance with a one-record partial file at this path. Please write to a temporary sibling and atomically rename only after extraction succeeds (or persist equivalent completion metadata), and cover partial-cache recovery.
There was a problem hiding this comment.
Implemented in 82b6dbb2. query_uniprot now extracts into a temporary sibling, verifies the complete write and ordered identifiers against the downloaded gzip, atomically replaces the query-addressed final path only after validation, and cleans compressed/staged artifacts in finally. The interruption regression writes partial bytes and proves neither the final cache nor a staged sibling survives; successful publication and same-query path reuse are covered. Verification: focused suite 21 passed; full non-slow Python suite 801 passed, 6 deselected; Ruff, notebook parse, strict OpenSpec, and pnpm precommit all passed.
- Remove the `written != len(content)` guard in query_uniprot; TextIOWrapper.write always returns len(s), so the OSError was unreachable. A real truncation still surfaces as an OSError from the close-time flush. - Patch `open` on the query module instead of `builtins` in the interrupted-extraction test, so the fake only intercepts opens made by query.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qoU16kDQxz6U3H2UWbbm2
Adversarial reviewReviewed in an isolated worktree by three independent lenses (code quality, adversarial correctness, issue-resolution audit), with every finding then put through a refuter whose default position was that it is a false positive. 13 raised, 8 survived refutation. Applied and pushed (
|
|
Follow-up to the 2026-08-06 adversarial review, addressed in DispositionsCleanups already applied in
Issue-audit gaps
Findings needing a decision
Verification
No review threads were resolved. |
- pipeline: keep the requested-identifier coverage check ahead of main's legacy TED label migration, so a cache that gets replaced is not rewritten first - notebook: keep main's import guard and _embedders_and_backend() gating, with this branch's per-query FASTA and per-input cache directories - docs: apps/protspace/docs/cli.md is now a pointer on main, so the annotation-cache identity note moves to docs/guide/python-cli.md and docs/guide/fetching-and-caching.md Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
- query_uniprot: one `partial` path replaces staged/extracted/completed bookkeeping; a uuid sibling opened normally gets the process umask, so the umask read + chmod goes away - _fetch_annotations: an uncovering cache falls through to the existing fresh-fetch branch instead of a closure, and is dropped before the rebuild rather than held alongside it - _input_cache_dir uses hashlib.file_digest and creates the directory, so the notebook's query and FASTA branches share one embed loop - tests: the notebook refetch pin moves to test_notebooks.py on its existing _code_cells/_literal_set helpers (no eval, IPython-transformed); cache tests reuse _cache_pipeline/_make_es and are parametrized; the interrupted-write scaffold becomes an identifier-mismatch test, which also covers that previously untested branch Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
- notebook: align every embedding set with _validate_headers before bundling, as run() does, so a model that skipped a protein cannot shift coordinates onto the wrong rows - pipeline: a rebuild for a cache describing other proteins must not protect that cache's columns, or one failing source keeps the foreign file and discards everything the rebuild retrieved - pipeline: parse each FASTA once per run, and compare identifier sets rather than multisets - query: stream the download to disk once; the identifier cross-check compared two decompressions of the same bytes and could not disagree - notebook: fall back to identical copies of the new cache-path helpers while the released package lags the notebook Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
The notebook-scoped design hid seven reproducible defects for one caller and left them in `protspace prepare`: stale projections for a changed, reordered or grown same-name input; a unioned embedding cache; a cross-backend resume; a stale vector for an edited sequence; a query FASTA reused for a different query; and a full annotation refetch for one added protein. Renames the change to fix-cache-ownership and replaces the single notebook capability with intermediate-cache-ownership, atomic-file-publication and notebook-generate-output-identity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
…ws by their data Three of the seven reproductions in the change proposal, fixed for every caller rather than for the notebook: - projections: the cache key now includes a digest of the identifiers and the matrix, so a changed, reordered or grown input under one embedding name misses instead of returning the earlier run's coordinates. Measured at 2.7 GB/s, ~0.9 s for Swiss-Prot, once per set per run. - query FASTA: `query_cache_path` addresses a retained download by its query text, so `-q A` then `-q B` in one output directory no longer reuses A's sequences. - annotations: a source served from the cache is now fetched only for the identifiers the cache has no row for, and taxonomy only for organisms its cached lookup lacks. A subset run keeps the rows it did not ask about instead of replacing the cache with its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
`data/io/atomic.py` replaces the bundle writer's private copy, the statistics/annotation rewrite in `stats`, and the retained query FASTA's own staging. It stages with a plain `open` rather than `mkstemp`, so a published file carries the process umask: bundles were being written owner-only (0600) because renaming a private temp file into place keeps its mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
Now that identity lives in the pipeline and the store, the notebook drops the content-addressed directories, the projections refetch override and the three private imports it needed for them — which cell 1 could not have imported from the release it installs anyway. Each Generate action writes `protspace_<timestamp>.parquetbundle`, so two downloads are told apart: a fixed name lands as `data (1).parquetbundle` and opening the earlier file is issue #338's reported symptom. test_notebooks now pins the general rule (no notebook imports a private protspace name) rather than one release's fallback copies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
Annotation reuse is per identifier as well as per column; a retained query FASTA is addressed by its query; an embedding HDF5 records the backend and model that wrote it; projections are keyed by the matrix and identifier order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
An embedding cache was addressed by its file name alone, so both backends resumed by identifier: a Local-produced vector satisfied a Biocentral run's resume check and the two models were silently mixed in one dataset, an identifier whose sequence changed kept the vector of residues it no longer has, and `embed_fasta` returned everything the shared cache had ever accumulated rather than the FASTA it was asked about. The shared store now records who produced a file (`protspace_backend`, `protspace_model`) and, per protein, which residues its vector was computed from (`protspace_sequence_sha256`). Resuming from another producer's file raises, naming the recorded producer and the three remedies, and leaves that file untouched; a file recording no producer is adopted and stamped, and a protein carrying no digest is trusted, so existing caches keep working. A protein whose residues changed is outstanding work again: it is embedded again, its dataset and digest are replaced, and `finish_run` reads the digests so a re-embed that never landed cannot pass for a complete run. Both backends go through `store.begin_run` / `save_embeddings`, so neither can drift, and the digests are read in one pass over one open file — at 570K proteins a per-protein reopen would replace a seconds-long resume with 570K file opens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
A cleanup pass and a correctness pass over the changes this branch already
made.
Fixed:
- `_fetch_taxonomy` returned `{}` on any exception, discarding the cached
taxonomy it had just been handed. Only reachable since this branch started
calling it to fill in unseen organisms, where one failed lookup blanked the
taxonomy columns of every protein in the run.
- The annotation parquet, the legacy-TED rewrite and the projection `.npz`
wrote straight to their final path, while this branch made their existence
the next run's trust signal; an interrupted write left a half-file read as a
cache hit. All three now publish through `staged_write`.
- `staged_write` appended a full uuid4 hex -- 38 characters onto a name the
user chose -- so a long bundle name hit NAME_MAX. Truncated to 8.
- `_restrict_to` mutated the EmbeddingSet it was passed; it returns a copy.
- `_embedding_fingerprint` ran even when nothing would be cached, a full pass
over the matrix for a key nobody looks up.
- `_extract_cached_source` and `_extract_cached_taxonomy` walked the cache with
`iterrows()`, now hot on every "added an identifier" run.
Simplified:
- One `uncached_headers` rule, shared by the pipeline's cache-serve gate and
the manager's fill-in. They had separate copies keyed off different columns,
and the lifted rule reports every identifier uncached when no cache holds
rows, so an empty frame cannot serve as a complete one.
- `fingerprint` is a required keyword on the projection-cache methods: an empty
default silently restored the pre-fix key.
- The query FASTA's reuse rule moved next to the artifact it governs, as
`loaders.query.resolve_query_fasta`.
- Resume logging lives in `begin_run` rather than duplicated per backend.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn
Summary
Issue #338 reported stale projections after moving a DR slider. The parameter cache was never the cause — the report is reproduced by PCA (the first projection the app shows) taking none of those sliders, and by every Generate action downloading the same file name.
Auditing that flow found real staleness underneath, and none of it was notebook-specific. Each of these was reproduced against the CLI layout (
{output}/tmp, one file per model name) before the fix and verified fixed after:--backend localover a Biocentral cache-q Athen-q Bin one-oThe first iteration of this PR hid 1–6 for the notebook with content-addressed cache directories. This version puts identity in the shared layer instead, so
protspace prepare, the notebook and the hosted prep service all get it — and the notebook gets smaller.Fix
protspace_backend/protspace_modeland a per-protein residue digest. Another producer's file is refused with the remedies named; a changed sequence is re-embedded; unstamped files from older versions are adopted and stamped.embed_fastareturns only the requested FASTA's proteins, not everything the shared cache accumulated.protspace_<timestamp>.parquetbundle.Verification
openspec validate fix-cache-ownership --strict,pnpm precommit: pass.Notes
--backendagainst an existing cache now fails with guidance instead of silently mixing embedding spaces.fix-cache-ownershipand still needs/opsx:archiveas the last commit before merge.Closes #338
Closes #493
🤖 Generated with Claude Code
https://claude.ai/code/session_01P7zAyQDRnfDCw3o7c3eUAn