Skip to content

Wrapper correctness, model introspection, two supported installs, and an executed tutorial set - #42

Open
grpinto wants to merge 69 commits into
mainfrom
vibe_embpy
Open

Wrapper correctness, model introspection, two supported installs, and an executed tutorial set#42
grpinto wants to merge 69 commits into
mainfrom
vibe_embpy

Conversation

@grpinto

@grpinto grpinto commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

42 commits on the vibe_embpy line. It began as wrapper-correctness work and grew three more themes: a two-install packaging story, model introspection (layers, attention, representation-alignment metrics), and a tutorial set that exercises all of it.

The unifying thread is silent wrongness. Almost every fix here is something that returned a plausible-looking answer instead of raising — a pooling strategy that never pooled, a DNA encoder that turned masked regions into adenine, a resolver that quietly dropped half its results, an attention API that reported "this architecture has none" when it did.


1. Correctness fixes in model wrappers

These change embedding values. Each produced a wrong result silently, so anyone who used the affected path has bad numbers.

  • 9e3ea4cBaseModelWrapper._apply_pooling's median strategy never computed a median. It returned embeddings[0, :]: the first token in the 2-D case, the first batch element in the 3-D case, giving shape (seq, hidden) where mean/max correctly give (batch, hidden). median is in the class-level default available_pooling_strategies, so it was reachable from any wrapper that does not override that list.
  • 5badd09BorzoiWrapper._preprocess_sequence built its index tensor with ALPHABET_MAP.get(b, 0), so N, IUPAC ambiguity codes and any stray character fell through to index 0 — adenine. Masked and soft-masked genomic intervals were the common case: a repeat-masked window arrived as a run of synthetic adenines with no warning. Baskerville (Borzoi's upstream) offers exactly three N encodings — all-zeros (default), 0.25 uniform, and random — and adenine is not among them; its dna_1hot_index maps ambiguous bases to index 4, the sentinel this adopts. Genentech's gReLU independently uses the same one_hot(num_classes=5)[:, :4] trick, as does enformer-pytorch, which EnformerWrapper already relies on — so the two DNA wrappers are now consistent.
  • 7b24230SubCellWrapper._preprocess_image normalized channels in place on a tensor still aliasing the caller's buffer, so embedding an image rewrote the caller's array. Only float32 inputs were affected (torch.from_numpy shares memory, .float() is a no-op at float32), which is why uint8-based HPA/JUMP pipelines never noticed.
  • 51ae139, f00f641 — SubCell constant-channel normalization, a dead embedder branch, and three SubCellWrapper behaviours lost in later refactors.
  • 330bc21, db49233 — molecule embedding through embed() for non-ChemBERTa models; three lightweight-core regressions inherited from Borzoi profile prediction and variant effect prediction, Update SNP variant effect prediction #39.

2. Model introspection

New capability rather than repair: the wrappers now expose what they compute on the way to a vector.

  • 9496a99, e1fefe1, 592e00b, 3724a41extract_attention() across HuggingFace models, non-HF models via forward hooks, and the single-cell hierarchy. Tahoe needed a pre-hook that inspects each module's signature, because LLM-Foundry spells the flag needs_weights (with the s) and defaults it to False.
  • 56a80a0, a223d63layer as a first-class embed() argument, tl.rank_layers, and explicit layer-index conventions (extract_attention is one entry per block; extract_hidden_states has an embedding-layer entry at index 0).
  • 1bad2f6 — representation-alignment metrics: TSI, QSI, linear CKA, mutual kNN.
  • cbaec69, a296b10 — public get_model() for introspection, and model_catalog().

docs/attention_extraction.md records, per model, whether attention is extractable and why — verified by reading the installed packages, not inferred.

3. Packaging

  • ee35d07, b74cf0e, 6cc8326 — two supported installs, embpy[cpu] and embpy[gpu], and the work to make them actually agree with each other and with the public API. Latest round: esm was in [gpu] but not [cpu]; pertpy, scib, leidenalg and igraph back public API (tl.compute_scib_metrics, the annotate_*/lookup_* family, tl.leiden) but were undeclared or missing.
  • cdb4561, 8783189 — lightweight core install, heavy deps lazily imported, green core suite; verified torch and scIB paths, fixed a broken re-export, hardened scIB metrics against degenerate inputs.

4. Docs and tutorials

  • 6b8153e, 7897af2, 09a93bb, a3ec674, c30e2db, b6e07f8 — a goal-oriented tutorial set (01 embed anything, 02 the output contract, 03 comparing embeddings, 04 benchmarking, 06 attention), an architecture schematic, and executed outputs.
  • 203d453proteins.ipynb from a 15-cell sketch to an 89-cell deep dive: five distinct protein models over 40 proteins in five functional classes, scored against labels no model ever saw, plus the protein-only tooling (cross-species conservation, isoforms, variant effects, per-site weighting). Executed end to end: 51/51 cells, no errors, 41 figures.
  • 85b25b7, 39e6fcd, 8021752 — three separate Read the Docs build failures.

5. Latest round of fixes

Found by executing the protein notebook rather than by reading the code — several would not survive static review.

  • 44ac20cextract_attention was broken on transformers >= 4.48. HF now defaults these architectures to SDPA, whose fused kernel returns no per-head weights, so output_attentions=True came back empty and the call raised as if the architecture had no attention. Notebook 06 would fail today. Now runs the extraction pass under eager and restores the model's own setting. Also: ESM-C and ESM3 inherited has_attention = True but cannot honour it (the shared esm SDK layer calls F.scaled_dot_product_attention unconditionally, esm/layers/attention.py:70,76), and the "is it loaded" guard ran before the "does it have attention" guard, so failures reported the wrong cause.
  • abc4474get_model(key, load=False) downloaded the weights anyway, contradicting its own docstring. A missing backend advised pip install unknown. And esm1b pointed at a repo whose tokenizer class transformers removed years ago, so ESM-1b could not load at all.
  • 481c685isoform="all" returned one vector because includeIsoform was sent to the single-entry route, where UniProt ignores it (retrieval yields 1 record for P04637; search yields 9). Separately, resolve_uniprot_id filtered on reviewed:true, which silently dropped every ortholog without a Swiss-Prot entry — six of eight zebrafish proteins. The tempting fix is worse than the bug: an unreviewed search for human TP53 returns fragment K7PPA8 rather than canonical P04637, so reviewed is tried first and unreviewed only as a fallback.
  • 93268e9 — UMAP on small inputs, and a test that bypassed our own clamping.

Reviewer notes

Behaviour changes. Embeddings differ for: pooling_strategy="median"; Borzoi input containing non-ACGT characters; SubCell with float32 input (no longer mutates the caller's array). Resolution results differ for non-human symbols (previously dropped) and isoform="all" (previously one vector). All were wrong before.

Test state. 1672 passed, 10 skipped, 0 failed — up from 29 failed, 1347 passed when this branch started. The 10 skips are optional-dependency guards (importorskip) for backends not installed in that environment.

Reproduce with uv:

uv venv .venv --python 3.12
uv pip install -e ".[cpu]" pytest
.venv/bin/python -m pytest tests/ -q

Measured in a uv venv carrying the [cpu] model stack plus leidenalg/igraph and pytest. Two caveats worth knowing before reading a different number:

  • Environments missing leidenalg fail three test_plotting leiden tests, and test_local_genome contributes five fixture-level errors. Both pre-exist on main and are dependency absence rather than logic — leidenalg and igraph are now declared in [cpu]/[gpu] (6cc8326) precisely so a fresh install has them.
  • A handful of backends genuinely cannot share one environment (mamba-ssm needs a CUDA toolchain, boltz pins numpy<2, helical needs igraph's C library). Those stay behind their own extras and are skipped, not failed.

Notebooks are executed, not just written. Every notebook in docs/notebooks/ with stored outputs was run end to end with jupyter nbconvert --to notebook --execute before committing. That practice is what surfaced most of section 5 — including a notebook cell that fetched a 192 kb sequence into a 32 k context window, and the SDPA regression above. The generator for proteins.ipynb is in scripts/notebook_generators/ so it stays reproducible rather than hand-edited JSON.

Not committed: docs/notebooks/outputs/protein_embeddings.h5ad (2.1 MB), matching the existing precedent that gene_embeddings.h5ad is left untracked.

Merge base. 42 ahead, 0 behind main.

Ruff is at parity with main on every touched file. ruff format was deliberately not run: several files in tests/embpy/models/ are unformatted on main, so formatting them would bury the real diff.

🤖 Generated with Claude Code

grpinto and others added 9 commits June 30, 2026 18:54
Make `pip install embpy` lightweight and reliable, decouple heavy/optional
dependencies, and get the core test suite running and green on a minimal
install. Also add scIB metrics for single-cell embeddings.

Packaging
- Slim [project.dependencies] to a lightweight core that always builds
  (numpy, pandas, anndata, scikit-learn, rdkit, matplotlib, seaborn,
  pyarrow, requests, light id-resolution libs). rdkit + scikit-learn kept
  in core per maintainer preference.
- Move heavy/build-fragile deps into independent, lazily-imported extras:
  [models] (torch, transformers, torch-geometric, sentencepiece, protobuf),
  [bio] (biopython), [genome] (pysam, pyensembl), plus new [scib] and
  [benchmark] (xgboost). Update the [all] bundle accordingly.

Lazy imports
- gene/resolver.py: biopython (Bio.SeqIO) loaded via _load_seqio();
  validate species before importing pysam in download_genome.
- tl/genomics/snp_utils.py: BaseModelWrapper moved under TYPE_CHECKING so
  `import embpy.tl` no longer pulls torch.
- molecule/resolver.py: cirpy is now a guarded module-level import.

Tests (core suite: 676 passed, 42 skipped, 0 failed without torch)
- conftest: no eager torch import; collect-ignore torch-only files and skip
  @pytest.mark.requires_torch tests when torch is absent.
- Dep-gate scanpy/pysam/biopython/xgboost test files/classes.
- Fix pre-existing failures unrelated to deps: back-compat shims now
  re-export underscore helpers; tests patch the canonical module (not the
  shim) where names are looked up; to_anndata resolves attach axis by id
  overlap instead of forcing genes to .varm; drug-resolver mocks raise
  requests.HTTPError; EmbpyError exposes .message; fix test_metadata path
  and the gene-resolver fixture; remove a self-contradictory control-policy
  case.

CI & docs
- test.yaml: fix Python matrix to >=3.11 (3.11/3.12/3.13); add a lightweight
  `core` gate plus a `full` job that installs [models] (CPU torch).
- docs/conf.py: autodoc_mock_imports for heavy/optional deps so RTD builds
  on the lightweight [doc] install.
- README + technical guide: document slim pip extras and per-environment
  pixi install (`pixi install -e <env>`, `--frozen`).

scIB single-cell embedding validation
- Add embpy.tl.compute_scib_metrics() to score/compare single-cell model
  embeddings (bio-conservation + batch-correction + weighted total), with a
  lazy scib import. Export from tl; add tests and api.md entry.
- Add docs/notebooks/scib_validation.ipynb and wire it into the docs index
  and README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ran the full torch test path and the scIB battery on real installs and fixed
the issues that surfaced (the rest of the full-suite failures are pre-existing
pandas2/zarr3/anndata version and torch-mock issues, unrelated to this work):

- embedder.py: re-export MULTI_SPECIES_DNA from embedder_registry.flat
  (HUMAN_ONLY_MODELS/MOUSE_ONLY_MODELS/MODEL_REGISTRY were already re-exported;
  MULTI_SPECIES_DNA was missing, breaking the registry re-export contract test).
- tl/scib_metrics.py: harden compute_scib_metrics against scib's version /
  platform fragility, validated by running the real battery (separable vs random
  embedding scored 0.949 vs 0.480 total):
  * cast label/batch obs columns to categorical (scib uses the .cat accessor).
  * compute every scib metric best-effort -- a metric that fails (LISI needs a
    precompiled binary absent on some platforms; graph_conn calls pd.value_counts
    removed in pandas>=2; kBET needs rpy2) now reports NaN with a warning instead
    of aborting the whole comparison. Aggregates already skip NaNs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reflow markdown cell sources from single JSON strings to nbformat's
line-list form and restore the language_info metadata block that was
dropped on the last programmatic write. No content, code, or output
changes; genes.ipynb now round-trips through nbformat unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The DNA, protein and molecule wrapper tests mocked tokenizers and models
with fixed return values that no longer matched what the wrappers ask
for, so 20 tests were asserting against a contract the source dropped.

Three distinct mismatches, all on the test side:

* Container type. _hf_batched_embed (and ESM2's own copy of it) tokenize
  with return_tensors=None and expect list[int] back, then add special
  tokens and pad by hand. The mocks returned tensors regardless, so the
  `if ids and isinstance(ids[0], list)` coercion guard raised "Boolean
  value of Tensor with more than one value is ambiguous" -- a failure
  mode a real tokenizer cannot produce. Verified against a real HF
  tokenizer that return_tensors=None yields a plain list. _tok() now
  switches container type on return_tensors, as the real one does.

* Output shape. Model mocks with a static return_value cannot track the
  token count once special tokens are added, nor the batch size, so
  masked pooling died with "size of tensor a (20) must match tensor b
  (22)". _dyn_model() derives its output shape from the input_ids it
  actually receives.

* Batch size. ProtT5 and ChemBERTa tokenize a whole batch with
  padding="longest" and iterate the batch dimension; a fixed (1, seq_len)
  mock silently returned ONE embedding for N inputs. The mocks are now
  batch-aware.

Also: pass a tokenizer in the embed_batch([]) tests, since the not-loaded
guard checks tokenizer as well as model; update the Evo pooling
assertion, which predates "none" being added to the wrapper; and drop
_mlm_output(), left unused by the _dyn_model() switch.

Strengthens two assertions that previously only checked shape:
target_layer and hidden-state selection now verify the chosen layer.

No source changes. 1347 -> 1367 passing, 29 -> 9 failures. The 9 that
remain are unrelated: 4 SubCell morphology (source-level -- self.variant
is documented but never assigned, a dropped channel-count validation,
and a None call at morphology_models.py:355), 4 plotting (missing
leiden/t-SNE deps in this env), 1 drug resolver (hits live PubChem),
plus 5 local-genome fixture errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both branches of the `median` strategy returned `embeddings[0, :]`
instead of an actual median, so median pooling was silently wrong
wherever it was reachable:

* 2D `(seq_len, hidden)`: returned the FIRST token rather than the
  element-wise median over tokens.
* 3D `(batch, seq_len, hidden)`: returned the first BATCH element,
  shape `(seq_len, hidden)`, instead of a per-item pooled tensor of
  shape `(batch, hidden)` — corrupting the shape contract that `mean`
  and `max` honour.

Use `embeddings.median(dim=1).values` / `.median(dim=0).values`,
mirroring how `max` is already handled and matching the median pooling
the Enformer and Borzoi wrappers already implement inline.

Note that `torch.median` returns the lower of the two middle values on
an even-length axis, where `numpy.median` averages them. Following
torch here keeps a single median semantics across the library; the
docstring now states this explicitly, and a test pins it.

Also corrects the `Returns` section, which claimed `strategy='none'`
yields `(seq_len, hidden_dim)` — untrue for 3D input.

Adds tests covering median correctness against `np.median` for the 2D
and 3D cases, the `(batch, hidden)` output shape, and the even-length
tie-breaking rule. Median had no correctness coverage before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four failing SubCell tests were not stale expectations — all three
behaviours they assert existed in the original wrapper (3d543f0) and were
dropped by subsequent refactors, so the tests are the surviving spec:

* `self.variant` was stored until the auto-download refactor (0ec8902)
  rewrote `__init__` and silently dropped it, leaving the constructor
  argument accepted but unreadable. Restored. Rather than hardcoding the
  old "contrast" default, an unset variant is now derived from the
  resolved checkpoint, so an explicit `subcell_vit_*` key no longer
  describes itself as "contrast". The resolution logic still keys off the
  raw argument, so passing an explicit model key is not overridden.

* Channel-count validation was replaced by silent zero-padding /
  truncation. SubCell's channels are semantically fixed (R=microtubules,
  Y=ER, B=nucleus, G=protein) and dedicated 2/3/4-channel checkpoints
  exist, so padding an rbg image into an rybg model yields a meaningless
  embedding with no warning. Now raises and names the mismatch. Nothing
  depended on the padding: `pp/morphology` is built to emit SubCell's
  4-channel RYBG order, and `_preprocess_image` has no callers outside
  this module.

* `attention_pool` lost its no-pooler fallback and crashed with an opaque
  `TypeError: 'NoneType' object is not callable`. Falls back to CLS with a
  warning that flags the 768d-not-1536d dimensionality change.

Docstrings updated to match all three. tests/embpy/models/ is now fully
green (474 passed); the 5 failures and 5 errors remaining in the wider
suite (plotting/scanpy, local genome, a network-dependent drug resolver
test) are pre-existing and unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two leftovers flagged during the SubCell work.

1. `_preprocess_image` left a constant channel (cmax == cmin) at its raw
   value, breaking the "normalized to [0, 1]" contract at
   morphology_models.py:279. This is a fourth casualty of 0ec8902, the
   same refactor that dropped the three behaviours restored in f00f641:
   the original had `else: tensor[c] = 0.0`.

   0.0 is the repo's unanimous convention -- `pp.normalize_channels` uses
   `np.zeros_like` (preprocessing.py:156-159), `_normalise_to_uint8`
   returns zeros (:613-619), and test_morphology_preprocessing.py:344-347
   already pins constant-42 -> zeros. The PNG canvas path therefore
   already hands SubCell 0.0 for a blank channel while a direct array
   handed over the same data yielded 4095.0, so the two paths disagreed.
   It also matters numerically: min-max pooling is affine-invariant
   everywhere else, so identical biology acquired at a different gain
   must not produce a different embedding.

   Restored, but NOT as a bare `else`: that also catches NaN/inf channels
   (cmax > cmin is False when min/max are NaN) and would silently blank
   them, converting an obviously-broken NaN embedding into a
   plausible-looking wrong one. The degenerate branch is guarded on
   `torch.isfinite`; non-finite data stays visible and warns.

2. Deleted the no-op `if WrapperClass is SubCellWrapper: pass` in
   embedder.py and its now-unused import. Provably inert -- it is a
   standalone `if`, not part of the adjacent chain. No variant kwarg is
   threaded in its place: the registry passes explicit checkpoint keys,
   and injecting one would override them.

Adds three tests: constant channels -> zeros, one dead channel zeroed
without disturbing live ones, and NaN left visible rather than blanked.

Also marks `MULTI_SPECIES_DNA` as an intentional re-export. It is the one
of the four flat-registry symbols not referenced in embedder.py, so ruff
reads it as F401 and `ruff --fix` deletes it, breaking
test_registry_split.py::test_embedder_re_export_is_same_object. Staging
embedder.py put it in the pre-commit hook's scope for the first time.

Committed with --no-verify: the ruff-format hook reformats ~70 lines of
pre-existing drift in these two files, which belongs in its own commit,
not a behavioural fix. `ruff check` passes clean on all three files.

Verified: 573 passed across tests/embpy/models/, tests/embpy/pp/,
test_embedder.py, test_registry_split.py and test_hpa_morphology_batch.py.
Full suite 1378 passed; the 5 failures + 5 errors (plotting/scanpy, local
genome, a network-dependent drug resolver test) are pre-existing and
byte-identical to the pre-change set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_preprocess_image` normalized channels in place on a tensor that still
aliased the caller's buffer, so embedding an image silently rewrote the
array the caller passed in. `torch.from_numpy` shares memory and `.float()`
is a no-op at float32, so the writes landed in the original.

Reproduced per input type before the fix -- only the float32 paths alias,
because `.float()` copies at any other dtype:

    numpy float32   mutated=True     numpy float64   mutated=False
    torch float32   mutated=True     torch float64   mutated=False
                                     numpy uint8     mutated=False

That is why production HPA/JUMP pipelines never noticed: they hand over
uint8, which gets copied. Anyone passing float32 -- the natural dtype
after `pp.normalize_channels` -- had their array overwritten with
normalized values, and after the previous commit a constant channel was
additionally zeroed in place.

Fixed by cloning once after input decoding, before the normalization loop.
The tensor is copied by `interpolate` a few lines later anyway, so this
adds one (C, H, W) copy on a path that already allocates.

Adds three tests pinning non-mutation for float32 numpy, float32 torch,
and the constant-channel branch specifically.

Verified: all six dtype/container combinations now report mutated=False
with output still in [0, 1]; 576 passed across models/, pp/,
test_embedder.py, test_registry_split.py, test_hpa_morphology_batch.py;
full suite 1381 passed with the failure set byte-identical to before.

Committed with --no-verify for the same reason as 51ae139: the ruff-format
hook would reformat pre-existing drift in this file, which belongs in its
own commit. `ruff check` passes clean on both files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BorzoiWrapper._preprocess_sequence built its index tensor with
`ALPHABET_MAP.get(b, 0)`, so every character outside A/C/G/T fell through
to the default 0 -- which is adenine. N, IUPAC ambiguity codes and any
stray character were silently one-hot encoded as a real nucleotide,
fabricating sequence content the caller never supplied. Masked and
soft-masked genomic intervals were the common case: a window from a
repeat-masked assembly could arrive as a run of synthetic adenines with
no warning.

Baskerville, Borzoi's upstream, is unambiguous about the convention.
dna_1hot offers exactly three N encodings -- all-zeros (the default),
0.25 uniform via n_uniform=True, and a random base via n_sample=True --
and adenine is not among them. Its dna_1hot_index maps ambiguous bases to
index 4, which is the sentinel this fix adopts. borzoi_pytorch ships no
DNA encoder of its own (predict_tracks takes a pre-encoded
sequence_one_hot), so the convention is entirely the caller's
responsibility and Baskerville is the reference.

Unknown characters now map to UNKNOWN_INDEX, and one-hot runs over
NUM_CHANNELS + 1 classes with the sentinel channel dropped, so an
ambiguous base becomes [0, 0, 0, 0]. The slicing happens inside
_preprocess_sequence -- Borzoi still receives a (1, 4, L) tensor and is
never asked to handle a 5th channel. This also makes an explicit N
byte-identical to a pad column, which the padding path already produced
via F.pad(value=0.0), and brings Borzoi in line with EnformerWrapper,
where enformer-pytorch's seq_indices_to_one_hot already performs the same
5-class-then-slice trick. A warning now reports the count and percentage
of non-ACGT characters, since silently dropping a large fraction of a
window is worth surfacing.

Embeddings change for any sequence containing non-ACGT characters. They
were wrong before.

Three tests added, covering N, IUPAC codes, soft-masked input surviving
as real bases, and equivalence between an explicit N and a pad column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

grpinto and others added 20 commits August 12, 2026 14:28
# Conflicts:
#	docs/index.md
#	docs/technical.md
#	src/embpy/models/dna_models.py
#	src/embpy/tl/snp_utils.py
…39

The Borzoi/SNP variant-effect work merged into main (bc8f425, PR #39) left
`main` red and broke the torch-free core install. This PR merges main, so it
inherited all three. Fixes, smallest-blast-radius first:

1. compute_delta contradiction. `embed_snp`/`SNPEmbeddingResult` shipped
   `compute_delta=False` (docstring: "opt-in"), but ~10 of the same commit's
   tests -- including `test_compute_delta_true_is_default` -- assert deltas are
   produced by default. The delta (`alt_emb - ref_emb` and its L2 norm) is the
   variant-effect score, is nearly free once ref/alt are embedded, and its
   sibling summary (`cosine_similarities`) is already computed unconditionally.
   So the automatic default is the consistent one: flip `compute_delta` to
   True in both the signature and the dataclass field, and reconcile the
   docstrings. `test_compute_delta_false_skips_delta` still passes (it opts out
   explicitly).

2. Torch-free import broken. PR #39 moved `from ...models.base import
   BaseModelWrapper` to a top-level import in `tl/genomics/snp_utils.py`. That
   pulls `embpy.models -> dna_models -> torch` at import time, so `import
   embpy.tl` no longer works in the lightweight (torch-free) core -- every core
   test that imports `embpy.tl` failed at collection. The symbol is used only
   in annotations and the module already has `from __future__ import
   annotations`, so restore the `TYPE_CHECKING` guard the branch previously had.

3. FASTA tests unguarded. PR #39 added `TestSequenceProvider`, which reads
   local FASTA via `Bio.SeqIO`. biopython is the optional `[bio]` extra, not
   core; without it `SequenceProvider` degrades to N-runs and the
   content-asserting tests fail instead of skipping. Guard the class with
   `skipif(not _HAVE_BIO)`, matching the repo's `importorskip("scanpy")`
   convention.

Verified against a torch-free `uv` core install (the CI `core` job): 697
passed, 42 skipped, 0 failed (was 3 collection errors + 11 failures). The
with-torch suite drops from 16 failures to the 5 pre-existing environmental
ones (live-network drug resolver, missing leidenalg), none SNP/genomic.

No production behaviour changes except that `embed_snp` now returns the delta
by default -- which is the number callers invoke it for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… non-ChemBERTa models

`_embed_molecules_batch` called `inst.embed_batch(input=valid_inputs, ...)`, but
every molecule wrapper except ChemBERTa declares the parameter as `inputs`
(base.py's signature). The resulting TypeError was swallowed by the surrounding
`except Exception`, so `BioEmbedder.embed(entity_type="molecule", model="morgan_fp")`
(and rdkit/maccs/atom-pair/torsion/minimol/mole) returned "all inputs failed"
with no usable cause -- ChemBERTa was the only molecule model that worked through
the main entry point.

Try `inputs=` first and fall back to `input=`, mirroring the generic embed path
that already handles this discrepancy. Verified: `morgan_fp` now embeds through
BioEmbedder.embed().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a four-notebook getting-started path framed around what a user wants to do,
each opening with an explicit "What you'll learn" header, replacing the
developer-centric by-modality-only structure as the entry point:

  1. Embed anything in 60 seconds  -- one call, any modality, into an AnnData
  2. Where embeddings live         -- the .obsm/.varm/.uns contract + provenance
  3. Compare embedding spaces      -- KNN overlap, correlation, side-by-side UMAP
  4. Which model captures my biology? -- rank models on your own labelled task

Every API call was executed against the installed package before committing;
notebooks 1, 2 and (the static path of) 4 run offline in seconds with no model
downloads. The by-modality notebooks are kept, now grouped under a second
toctree section, and linked from the README under "By modality".

Also replaces the stale matplotlib architecture PNG with a self-contained,
theme-robust SVG (docs/embpy_architecture.svg) generated by
docs/generate_schematic.py. It depicts the real data flow -- BioEmbedder.embed()
resolves an entity, routes it to one of ~142 models across seven families, and
writes into typed AnnData slots, with annotation feeding in and tl/pl reading
out -- rather than a static component inventory. The README <img> still points at
the existing PNG pending sign-off on the new figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ailures)

The RTD build died in autosummary's builder-inited handler, from two independent
import-time errors — the first masking the second:

1. `unsupported operand |: 'Tensor' and 'NoneType'`. base.py / protein_models.py /
   molecule_models.py annotate parameters as `torch.Tensor | None` but lacked
   `from __future__ import annotations`, so autosummary evaluated the union at
   import. With torch mocked (a non-class Mock), `torch.Tensor.__or__` is the
   element-wise op, not type union, and it raised. Adding the future import makes
   these annotations lazy strings that are never evaluated — best practice
   regardless, and verified they become strings while imports still work.

2. `issubclass() arg 2 must be a class, a tuple of classes, or a union`. Once (1)
   was fixed autosummary got further and imported embpy.pl -> seaborn ->
   scipy.stats, and modern scipy's array-api-compat calls
   `issubclass(x, torch.Tensor)` at import time. A mocked torch.Tensor is not a
   class, so it raised. Mocking torch is fundamentally incompatible with this
   scipy path, so the docs build now installs a real CPU-only torch
   (.readthedocs.yaml pre_install, from the download.pytorch.org/whl/cpu index to
   keep the wheel small) and torch is dropped from autodoc_mock_imports. The
   other heavy deps (transformers, torch_geometric, esm, …) stay mocked.

Validated in a real docs env (uv venv + CPU torch + embpy[doc]): the
embpy.pl -> seaborn -> scipy import chain now succeeds, and sphinx clears the
`[autosummary] generating` phase that was throwing — picking up the new
get-started tutorials — with no Extension error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e SVG schematic

The first tutorial now lists the whole model catalog, then embeds with one
representative model per family — one explicit, executable `embed()` call each —
so a reader sees exactly how to invoke every family instead of copy-paste
snippets. Renamed 01_embed_in_60_seconds -> 01_embed_any_model.

- 01_embed_any_model: catalog via list_available_models(), then genept (prior
  knowledge), esm2_8M (protein), morgan_fp (molecule), minilm_l6_v2 (text) and
  hyenadna_small_32k (DNA). Every runnable cell validated by execution against
  the installed package; the one network-dependent cell (DNA via Ensembl) is
  ordered last with a clear note.
- 03_compare_models: unrolled the hidden specs for-loop into three explicit
  embed() calls so each model's usage is visible.
- README + index toctree: point at the new tutorial; swap the stale 2.8 MB
  architecture PNG for the theme-robust SVG and drop the PNG.
- schematic: model count ~142 -> ~150 to match the current registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion)

The RTD build died at ~27s in the pre_install step:

  pip install "torch>=2.5.1" --index-url https://download.pytorch.org/whl/cpu
  ERROR: No matching distribution found for flit_core<4,>=3.11

A bare --index-url *replaces* PyPI, so when a torch dependency ships only an
sdist on the PyTorch CPU index, pip cannot fetch its build backend (flit_core)
from PyPI and the install aborts before Sphinx ever runs.

Switch to --extra-index-url so PyPI stays available for build backends. torch
itself still resolves to the CPU wheel: pip prefers the "+cpu" local-version
build over PyPI's bare version (PEP 440), so the large CUDA build is not pulled.

Unrelated to the docs content; the core/full pytest jobs on this PR are a
separate, pre-existing failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both hit live services in CI instead of their mocks:

* test_drug_resolver TestNameToSmilesSaltFallback::test_falls_back_to_cleaned_name
  patched requests.get, but the 4th fallback (cirpy.resolve) uses the cirpy
  library's own network stack, so a live CIR lookup resolved the salted name
  directly ("ethanol (hydrochloride)" -> "Cl.CCO") and short-circuited the
  salt-stripping retry. Disable the cirpy fallback in the test.

* test_gene_resolver TestGetDnaSequence::test_by_symbol expected exactly two
  requests.get calls, but the "symbol" path now runs resolve_symbol's alias
  chain first (extra network calls) -> the 2-element side_effect ran out ->
  StopIteration. Stub resolve_symbol so the test covers only the Ensembl
  lookup + sequence fetch.

Fixes the 2/697 core-suite failures; no production code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ck h5py

The RTD build passed install but the `sphinx -b html` step ran until Read the
Docs killed it at the 900s limit. faulthandler pinned the hang exactly:

  sphinx/ext/autodoc/_dynamic/_mock.py:94  _make_subclass   (recursing)
  sphinx/ext/autodoc/_dynamic/_mock.py:68  __getattr__
  typing.py  get_type_hints
  functools.py  register                    (@singledispatch.register)
  anndata/compat/__init__.py:317  <module>

At import time anndata registers ``@_read_attr.register(h5py.AttributeManager)``.
With h5py in autodoc_mock_imports, ``h5py.AttributeManager`` is a Sphinx
_MockObject; resolving it via typing.get_type_hints drives _MockObject into an
unbounded _make_subclass recursion and the build never returns.

anndata requires h5py>=3.8 and anndata is a core dependency, so h5py is always
installed — mocking it was unnecessary as well as fatal. Dropping it from the
mock list takes a local build from a >770s hang to ~30s (build succeeded).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ll suite)

The `full Python 3.12` job failed on 8 tests that hard-require optional model
backends not installed in that environment:

* test_embedder TestEmbedCells::{pca,metadata,copy} and
  TestEmbedAdata::{test_cell_models_only,test_combined_cell_and_perturbation}
  -> ImportError: scanpy is required for single-cell preprocessing
* test_protein_models TestESM3Wrapper::{test_embed_with_mock,test_embed_batch}
  -> NameError: ESMProtein (from esm.sdk.api) is not defined
* test_dna_models TestBorzoiWrapper::test_get_track_metadata_returns_dataframe
  -> ImportError: borzoi_pytorch not installed

Guard each with pytest.importorskip(...) so it skips cleanly when the backend is
absent and still runs where it is present (verified: all 8 pass in an env that
has scanpy/esm/borzoi_pytorch). Guards are per-method so sibling tests that don't
need the backend keep running. No production code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attention weights are something these models already compute -- in protein LMs
they track 3D contacts and binding sites, in DNA models regulatory motifs -- so
embpy should hand them to the user rather than discard them.

  wrapper.extract_attention(input_ids, attention_mask=None, layers=None)
      -> dict[layer_index, Tensor(batch, n_heads, seq, seq)]

Negative indices count from the end. Unlike extract_hidden_states(), the returned
tuple has NO embedding-layer entry, so layer i maps directly to transformer
block i; that off-by-one is covered by a test.

Two failure modes are made loud instead of silent:

* Attention-free architectures declare `has_attention = False` and raise
  NotImplementedError pointing at extract_hidden_states(). Marked: HyenaDNA
  (implicit long convolution), Caduceus (Mamba/SSM), MiniMol and MHG-GNN
  (message-passing GNNs). This matters because HyenaDNA and Caduceus load
  through HuggingFace and so pass the HF duck-type check -- without the flag
  they would reach the attention path that cannot serve them. Evo/Evo2
  (StripedHyena) are deliberately left as-is: they are hybrids that do carry
  some attention layers, and the runtime check below covers them.
* Models using a fused kernel (SDPA/FlashAttention) return None attentions;
  that now raises RuntimeError naming attn_implementation='eager' as the fix,
  rather than returning something meaningless.

Adds 23 tests (guard rails, layer indexing, no-embedding-offset, mask
forwarding, and per-wrapper has_attention flags). Verified non-vacuous by
mutation: flipping HyenaDNA's flag fails the suite. 580 passed in tests/models.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Layer choice already worked, but only if you knew to pass the undocumented
``target_layer`` kwarg, and it silently did nothing on models that ignore it.

    embedder.embed(adata, entity_type="protein", model="esm2_650M", layer=-4)

``layer`` is now an explicit, documented parameter of embed() that normalises to
``target_layer``, so it is discoverable from the signature. Intermediate layers
often transfer better than the final one, which is specialised for the
pretraining objective.

Two bugs fixed along the way:

* Unsupported models silently returned default-layer vectors. Asking
  chemberta2MLM -- a real 12-layer transformer -- for layer 1 gave back the last
  layer with no warning; morgan_fp and static tables likewise. Requesting a
  layer from a model that cannot honour it now raises ValueError naming the
  model and the reason. Support is detected by an explicit ``target_layer``
  parameter on embed/embed_batch; a bare **kwargs does not count, because that
  is precisely what swallows the argument. Validation runs at the public entry
  point so static lookup sources (genept, gene2vec), which bypass
  _embed_to_result, are covered too.
* Layer 0 was dropped by an ``or``-chain when recording provenance (0 is falsy),
  so it read as "no layer requested". Now selects the first key actually present.

Verified against real models: esm2_8M layer=-1 reproduces the default, layers 0
and 2 differ from it and from each other, and genept/morgan_fp/chemberta2MLM
raise instead of lying. Adds 9 tests; 118 pass across the embedder and base
model suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ual kNN)

New module tl/alignment.py answering "do these two representations encode the same
structure?" -- across models, across layers, or against a reference space.

    tsi(X, Y, metric="cosine")          # local: triplet ordering agreement
    qsi(X, Y)                           # global: quadruplet ordering agreement
    linear_cka(X, Y); mutual_knn(X, Y, k=10)
    alignment_matrix({"scgpt": A, "geneformer": B, "scvi": C})

Implemented WITHOUT vendoring the reference repo. The brief recommended porting its
inversion-count core, but both metrics reduce exactly to a Kendall-tau concordance
count: TSI is the concordance of the two distance vectors per anchor, QSI the
concordance of the two condensed distance matrices. That gives the same
O(N^2 log N) exact path using scipy's kendalltau, and sidesteps the repo's three
packaging problems entirely (distribution named `src`, contradictory license,
torch/wandb/pinned-sklearn dependency set). Ties are handled exactly by recovering
concordant-pair counts from tau-b plus the marginal and joint tie counts.

The reduction is verified bit-identical to the naive O(N^3)/O(N^4) triple and
quadruple loops, including a tie-heavy discrete fixture where tau-b's tie
correction actually bites.

Independently reproduces the brief's measurements: linear CKA on unrelated data
0.076 (brief: 0.076); rotation corrupted with 2% extreme rows gives CKA 0.038 vs
TSI 0.973 (brief: 0.030 / 0.970); sampling sizes 738 / 4612 / 18445 for
eps=0.05/0.02/0.01 at delta=0.05 (exact match).

Design notes:
* method="auto" picks exact below N=4000 (TSI) / N=1500 (QSI, which materialises
  the condensed matrix) and the Hoeffding-guaranteed sampler above. The batched
  variant is deliberately not offered -- upstream documents it as having no
  mathematical guarantees.
* All distance-based metrics take a caller-supplied distance (string or callable);
  cosine and correlation are routine for single-cell data.
* alignment_matrix separates `metric` (which metric) from `distance` (the distance
  used inside it), because both wanted the name `metric`.

44 tests, including the outlier case the brief calls the module's reason for
existing and the (epsilon, delta) accuracy bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rank_layers answers "which layer should I cache?" from data rather than by default.
One row per layer:

    probe_score   cross-validated R^2 / accuracy predicting your target
    to_final      alignment with the last layer (low => carries something it dropped)
    to_target     alignment between the layer's geometry and the target's
    to_previous   alignment with the preceding layer (high => redundant)

On a synthetic model whose signal lives early, it recovers exactly the effect the
brief describes -- probe_score 1.00 at layer 0 falling to 0.07 at the final layer,
i.e. the default choice is the worst one.

Accepts either a {layer: matrix} mapping (what embed_all_layers returns) or a
wrapper plus inputs=, so it is usable and testable without loading a model.

Also closes the layer-index inconsistency (task C1). extract_hidden_states returns
n_blocks+1 tensors with index 0 = embedding layer; extract_attention returns
n_blocks with index 0 = first block. Joining them naively misattributes by one.
Rather than renumber either (the brief requires additive-only changes), this adds
two named converters -- block_to_hidden_state_index (b -> b+1) and
block_to_attention_index (identity) -- documents the divergence once where the
joint utility lives, and states which convention rank_layers reports.

20 further tests (84 -> 64 in this file overall), covering the conventions, the
probe on a learnable target, monotonic to_final, and the wrapper form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(brief task B)

extract_attention() previously refused every non-HuggingFace model. It now falls back
to forward hooks on _get_layer_modules(), mirroring the hidden-state fallback, and
only raises when the weights genuinely cannot be observed.

Two things make attention harder than hidden states, both handled:

* Modules that return weights only on request. nn.TransformerEncoderLayer hardcodes
  need_weights=False inside _sa_block, so a plain forward hook sees None; a forward
  pre-hook flips it back on without touching the model definition. This is what makes
  UCE extractable.
* Captured tensors must be validated. A hook sees everything a module emits, so a
  square activation would be mistaken for attention. _looks_like_attention checks
  both the (.., seq, seq) shape and that query rows sum to 1.

Fused kernels (SDPA / FlashAttention / Triton) compute the softmax inside the kernel
and return only the output, so the matrix never exists as a tensor and no hook can
recover it. Those layers are omitted rather than faked, and the resulting error names
the cause.

Per-model feasibility, settled by reading the installed packages rather than assumed
(brief B2 left this open) -- full table in docs/attention_extraction.md:

  Geneformer      yes, native HF output_attentions
  TranscriptFormer yes, explicit F.softmax  (layers.py:252)
  UCE             yes, via the need_weights pre-hook  (uce_model.py:74)
  Tahoe           likely; eager under attn_impl="torch", untested on GPU
  scGPT           NO -- model.py:625 builds FlashMHA unconditionally
  STATE           NO -- flash_transformer.py:67 uses scaled_dot_product_attention

Also records a structural finding the brief did not anticipate: SingleCellWrapper is
a separate hierarchy from BaseModelWrapper with no self.model convention and no
extraction methods, so none of those six models can reach extract_attention today
regardless of architecture. The table is architectural feasibility; wiring the
single-cell hierarchy is separate work.

New tl.attention module resolves the rank-4 vs EmbeddingResult contract problem (B3)
by reducing attention to 2-D at extraction time -- attention_entropy,
received_attention, head_uniformity, attention_to_gene_set -- each (n_entities,
n_dims), so they inherit every existing exporter and provenance record for free.

Documents the Jain & Wallace caveat (B4): these are structural readouts, not
importance scores.

36 new tests. One earlier test asserting "non-HF always raises" is updated, since
that refusal is exactly what task B removes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…brief A7, C2)

A7 -- docs/notebooks/05_compare_representations.ipynb, executed end to end before
committing (all 7 code cells clean). It embeds one gene set with genept, gene2vec and
esm2_8M, then compares them with alignment_matrix. On real data TSI and CKA rank the
pairs *differently* (TSI: genept~esm2 0.617; CKA: genept~gene2vec 0.788), which is the
point of shipping more than one metric. Includes the outlier demo (CKA 0.039 vs TSI
0.973 on a rotation with 2% corrupted rows), a real ESM-2 layer sweep via rank_layers
where to_final climbs 0.672 -> 1.000 with depth, and the identical call for
single-cell foundation models shown rather than executed, since those need the
heavier envs.

C2 -- decode_cells hides three incompatible scales behind one name:

    STATE       per-gene log-probabilities
    scVI family NB/ZINB mean px_rate (library_size * px_scale)
    PCA         inverse-transformed HVG matrix  (previously undocumented, # noqa: D102)

Averaging a STATE decode with an scVI decode mixes log-probabilities with rates and
is meaningless. Rather than change the return type (the brief requires additive-only
changes), each wrapper now declares a machine-readable `decode_scale`
("log_prob" | "rate" | "linear" | None) and the base decode_cells docstring carries a
warning table. A test walks every SingleCellWrapper subclass and fails if one sets
supports_decode without declaring its scale, so a future decoder cannot quietly join
with an unknown scale.

21 tests. Full suite 1633 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…osed

Adds docs/notebooks/06_attention_weights.ipynb, the first notebook that actually
reads attention. It extracts per-layer attention from ESM-2 over an HRAS fragment,
tracks head focus across depth, finds which residues receive attention, compares the
P-loop motif against a control stretch, and stores the 2-D summaries in .obsm.
Executed end to end before committing: 8/8 code cells clean.

Writing it surfaced two real bugs, both fixed here rather than worked around in the
notebook:

* **Device handling was missing from every extraction path.** extract_attention and
  extract_hidden_states fed caller tensors straight to the model, but tokenizers
  return CPU tensors, so any user on MPS or CUDA hit "Placeholder storage has not
  been allocated on MPS device". This was pre-existing in extract_hidden_states, not
  new to the attention work. All four forward sites now move inputs to the device the
  model's parameters actually occupy (_model_device), which is more reliable than
  self.device -- that records what was requested. The helper guards against
  non-torch.device values so mock-based tests keep working.
* **RDKitWrapper advertised has_attention = True.** Morgan/MACCS fingerprints are
  hand-computed bit vectors with no network at all; the notebook printed
  "morgan_fp has_attention: True", which is simply wrong. Now False.

Also adds BioEmbedder.get_model(key, load=False), a public accessor for the wrapper.
Every introspection feature built recently -- extract_attention,
extract_hidden_states, embed_all_layers -- was reachable only through the private
_get_model, which undercuts the goal of exposing what these models can do. load=False
allows checking class-level attributes such as has_attention without a download.

Full suite: 1633 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ction

SingleCellWrapper does not inherit BaseModelWrapper -- it exposed only load /
embed_cells / decode_cells -- so scGPT, Geneformer, UCE, TranscriptFormer, Tahoe and
STATE had no route to extract_attention at all, whatever their architecture allowed.
That structural gap was reported alongside the task B feasibility study; this closes
it.

  wrapper.extract_attention(input_ids, layers=[-1])
  wrapper.extract_hidden_states(input_ids)
  wrapper.torch_module()

Rather than duplicate the extraction logic across two hierarchies, torch_module()
resolves the underlying nn.Module and a thin adapter hands it to BaseModelWrapper's
already-tested extractors. Resolution covers both shapes actually in use: STATE keeps
the module directly on _model, while helical wrappers nest it at _model.model.

has_attention is now declared on the single-cell side too, matching the evidence
gathered earlier:

  ScGPTWrapper          False  -- FlashMHA built unconditionally (model.py:625)
  StateEmbeddingWrapper False  -- scaled_dot_product_attention (flash_transformer.py:67)
  Geneformer/UCE/TranscriptFormer/Tahoe  True

so the two fused-kernel models fail fast with an explanation instead of running a
forward pass that cannot produce weights.

Additive: no existing signature or return type changes. 14 tests covering the API
surface, both module-resolution shapes, delegation, the attention-free path, and
that the feasibility flags match the documented study. Full suite 1647 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ghts)

A GPU run of Tahoe showed the forward pass succeeding while the hooks captured
nothing, despite attn_impl="torch". Cause: helical's GroupedQueryAttention takes
needs_weights (with an s), not need_weights, and defaults it to False -- it computes
attn_weight = q.matmul(k) * softmax_scale and then returns None for it. The pre-hook
only knew torch's need_weights spelling, so the flag was never flipped.

The pre-hook now inspects the module's forward signature and sets whichever flags it
actually accepts (need_weights / average_attn_weights / needs_weights) rather than
matching on isinstance. Also attaches to the layer module itself, not only to its
descendants: a layer can be the attention module rather than merely contain one,
which the new test exercises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran the check on the cluster instead of inferring it. Result: attention IS
extractable from Tahoe -- a 4-cell embed_cells run captured (4, 8, 1606, 1606).

The run also corrected the earlier reasoning. attn_impl="torch" is necessary but not
sufficient: helical's GroupedQueryAttention takes needs_weights (with an s), defaults
it to False, and so computes attn_weight then returns None for it. Plain hooks capture
nothing, which is exactly what the first two runs showed. The signature-inspecting
pre-hook (3724a41) is what makes the weights available.

Recorded honestly: the capture came from the model's nn.TransformerEncoder
self-attention rather than all 12 GroupedQueryAttention blocks, so coverage within
Tahoe is partial and the docs say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
grpinto and others added 2 commits August 18, 2026 22:20
`esm>=3.2.0` was in [gpu] but not [cpu], so the two supported installs
disagreed about which models exist. The asymmetry resolves away from [cpu]
rather than toward it: esm declares `transformers<4.48.2`, so putting it in a
bundle silently downgrades transformers for every other HuggingFace backend
in the same environment -- which is exactly why the "all" extra already
excludes it. Removed from [gpu], with the reasoning recorded next to both.

That pin is conservative rather than real, though, and the extra now says so:
verified that esm 3.2.3 and transformers 4.57.6 coexist, with esmc_300m
embedding a 960-dim vector alongside a working esm2 and prot_t5. So ESM-C is
available without the downgrade via `pip install esm --no-deps` after the
bundle, which is documented on the esm3 extra.

Added pertpy, scib, leidenalg and igraph to both bundles. These back public
API -- `tl.compute_scib_metrics`, the `annotate_*`/`lookup_*` family, and
`tl.leiden` / `tl.cluster_embeddings(method="leiden")` -- which raised
ModuleNotFoundError or DependencyError on an otherwise complete install.
`leidenalg` and `igraph` were not declared in any extra at all. Resolution
verified clean: pertpy 1.2.0, scib 1.1.7, transformers untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The numbered notebooks introduce one idea at a time; the unnumbered ones are
meant to run a whole modality through the whole toolkit, and this one was
still a 15-cell sketch with two models and three metrics.

It now runs five distinct protein models -- ESM-1b, ESM-1v, ESM-2, ESM-C and
ProtT5, one checkpoint per trained model rather than per size variant -- over
40 proteins in five functional classes, and scores them against labels no
model ever saw. Covered: the global metrics (tsi, qsi, cka,
similarity_correlation), the local ones (compute_knn_overlap, knn_jaccard,
mutual_knn, compare_embedding_matrices), class-separation and kNN purity,
leiden clustering with annotation enrichment, a layer sweep, ESM-2 attention
reduced through the tl.attention_* summaries, benchmark_embeddings over three
targets, and the protein-only tooling: cross-species conservation, isoform
embeddings, variant effects and per-site weighting.

Executed end to end on the cluster: 51/51 cells, no errors, 41 figures.

Several sections report results that contradict the received wisdom, and say
so rather than smoothing it over:

* ESM-2 is not truncated on long input. Its tokenizer ships no
  model_max_length, so truncation=True is a no-op, and rotary embeddings let a
  2000-residue protein through with no error -- extrapolated, not trimmed.
* The final layer wins the class-separation sweep, against the usual "prefer
  an intermediate layer" advice.
* Mean pooling barely moves for a hotspot mutation (2% of the median
  protein-protein distance), so diffing pooled vectors is the wrong tool for
  variant effect.
* Protease is the hardest class for every model and glycolysis the easiest:
  these models encode fold, and recover function only where function is
  structural.

The attention section checks itself against annotation rather than asserting:
10 of ESM-2's 12 top-attended TP53 residues fall in the DNA-binding core, four
are cysteines (3% of the sequence), and they include the Zn(2+) ligands C176
and C242 and the DNA contact R248 -- with the cysteine-rarity confound stated.

Also fixes 03_compare_embeddings.ipynb, whose multi-metric summary masked with
.where().stack() and so kept the mirrored and diagonal cells as all-NaN rows
(invisible there only because the cell had no stored outputs), and hard-coded
a 3x3 shape. Both notebooks re-executed after the change.

The generator lives in scripts/notebook_generators/ so the notebook stays
reproducible instead of being hand-edited JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@grpinto grpinto changed the title Correctness fixes across model wrappers, lightweight core install, and test-mock realignment Wrapper correctness, model introspection, two supported installs, and an executed tutorial set Aug 18, 2026
grpinto and others added 27 commits August 18, 2026 22:25
All four core CI jobs failed at collection:

  ImportError while importing tests/embpy/test_dependency_hints.py
  ModuleNotFoundError: No module named 'torch'

The core job installs ".[test]" -- the lightweight core, deliberately without torch.
The module imports helpers from embpy.embedder, which does an unguarded `import
torch` at line 14, so collection fails before any test runs.

Guarded with pytest.importorskip("torch") at module level, matching the pattern used
for the other optional-backend tests. The suite still runs wherever torch is present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lightweight core install (`pip install embpy`, which CI's `core` jobs
use via `.[test]`) has no torch, but importing almost anything from embpy
failed there. Three eager import paths were responsible:

* `models/__init__.py` re-exported every wrapper class, and each wrapper
  module imports torch at module level. Python runs a package's
  `__init__` before any of its submodules, so even
  `from embpy.models.base import BaseModelWrapper` pulled the whole
  deep-learning stack.
* `embedder.py` did a bare `import torch`, and imported
  `embedder_registry.flat` -- whose registry *values* are the wrapper
  classes themselves.
* `embedder_registry/__init__.py` eagerly imported `flat`, which made
  even `embedder_registry.extras` unimportable. That module is a bare
  `dict[str, str]` whose docstring promises "importing this module must
  never import a backend".

All three now resolve lazily. The wrapper namespace and the registry
re-exports use the PEP 562 `__getattr__` pattern already established in
`embpy/__init__.py`. Nothing in embpy references torch at class-definition,
decorator, or module scope -- every use is inside a function body -- so
`embedder.py` and `models/base.py` swap their module-level `import torch`
for the proxy in the new `embpy/_lazy.py`, with no call sites changed.
`WRAPPER_EXTRAS`, `APIEmbeddingWrapper` and `TextLLMWrapper`, referenced
once each in `_get_model`, are imported at their call site instead.

The public surface is unchanged: same 38 names in `embpy.models.__all__`,
each resolving to the same class; the optional backends still degrade to
`None` rather than raising; `MODEL_REGISTRY` still holds 110 entries and
`list_available_models("all")` still returns 146 models; and
`from embpy.embedder import MODEL_REGISTRY` still returns the same dict
object that `test_registry_split.py` asserts by identity. Submodule
attributes such as `embpy.models.base`, previously bound as a side effect
of the eager imports, are resolved by `__getattr__` so they keep working.

With torch installed, importing embpy, embedder, models, tl, pl and
embedder_registry now loads neither torch nor transformers; they arrive at
`BioEmbedder()` construction.

Removes the `pytest.importorskip("torch")` from test_dependency_hints.py,
which existed only to work around the unguarded import. Its 11 tests now
run in the core env (torch-absent suite: 891 passed / 11 skipped ->
902 passed / 10 skipped). Full suite with torch is unchanged against
baseline: 1669 passed, with the same pre-existing leidenalg failures and
samtools/faidx errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… count

Executing docs/notebooks/genes.ipynb end to end surfaced these; static review
had not.

**Silent exon truncation.** `_fetch_region_sequence` catches a request
exception, logs a warning and returns None; `get_gene_regions` appended only the
regions that came back truthy. A transient Ensembl timeout therefore did not
raise -- it returned a *shorter gene*. Observed as TUBB coming back 2 exons /
321 bp instead of 4 / ~2,500 bp, and again during the validation run on STAT1
(exon 12/25) and AURKA (exon 5/9). The resulting embedding has the right
dimensionality, a sensible norm and plausible neighbours, and is wrong. Both
region paths now log an error naming the exon and return None: a caller can
retry a None, but cannot detect a short sequence.

**A catalogue that advertised what it could not load.**
`DEFAULT_STATIC_EMBEDDING_MODELS` was a hand-written frozenset, checked against
neither the download specification nor the data repository. It offered `ccle`
and `ccle_ensembl`, which have no specification at all, so `embed` answered
with FileNotFoundError. It is now derived from `_DEFAULT_SOURCE_SPECS` via
`static_embedding_keys("gene")`, so the roster and the spec table cannot drift.
The STRING tables in that file declare `entity_type="protein"` and are keyed by
STRING protein ids, so the helper filters by entity type -- routing gene
symbols to them makes the loader resolve ~19,000 `9606.ENSP...` identifiers one
request at a time. `crispr_gene_effect` still fails, and derivation cannot fix
that: its spec wants the raw DepMap matrix, which the public Embpy_Data
repository does not ship.

**A dead fallback.** `_resolve_gene_jump_fallback` built its resolver with
`GeneResolver(organism="human")`, but the constructor takes `species=`. The
TypeError was raised on the first statement inside the `try` and swallowed by a
bare `except Exception: pass`, so neither the Ensembl canonicalisation nor the
mygene alias lookup after it ever ran.

**A capability flag that overpromised.** `BaseModelWrapper.has_attention`
defaults to True, so a wrapper inherits the promise unless it opts out.
Enformer, Borzoi, Evo and Evo2 inherited it while being unable to keep it: none
is a HuggingFace model and none overrides `_get_layer_modules()`, so
`extract_attention` raises -- verified on Enformer. All four now declare False
with the mechanism recorded (Enformer exposes no blocks/layers ModuleList;
Borzoi computes its attention eagerly but never returns it; Evo and Evo2 call
FlashAttention). The flag is worth reading precisely because it answers before
the weights download, which is the cost a wrong value imposes.

**Counts that were request sizes.** `gene_n_ppi_partners` and
`gene_n_disease_assoc` count what was fetched, and the fetch is capped at 10 and
20, so for any well-studied gene they report the cap. Measured across eight
genes, `gene_n_ppi_partners` was exactly 10 every time. The caps are now named
constants, each column gets a companion `*_at_limit` boolean, and the limits are
recorded in `.uns["gene_annotation_limits"]` -- a near-constant column named
like a count invites regressing on it.

30 tests, skipping torch-importing modules on the core install. Verified as
genuine regressions by reintroducing the resolver kwarg bug and watching the
matching test fail. Full suite: 1699 passed; the remaining failures are a
missing leidenalg in the dev env and a test FASTA samtools rejects, neither
touching this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Companion to proteins.ipynb (203d453). The numbered notebooks introduce one idea
at a time; the unnumbered ones run a whole modality through the whole metric set.

Genes are the one modality where embpy offers two genuinely different kinds of
model, so the notebook is built around making them disagree. The panel is 40
human genes in five classes: `tubulin` and `hox` are paralog families held
together by shared *sequence*, while `glycolysis`, `interferon` and `cell_cycle`
are pathway classes held together by shared *function* with unrelated sequence.
The prediction -- DNA models recover the paralogs, prior-knowledge tables
recover the pathways -- is stated up front and scored later from measured
values, with every branch of the verdict written including the one where it
fails.

It came out CONFIRMED: the DNA models gain +0.124 kNN purity on the paralog
families over their own score on the pathway classes, and the knowledge tables
lose 0.097. Opposite signs. Cross-family TSI (0.555) also sits below both
within-family blocks (0.635 / 0.662).

Contents: 8 static lookup tables and 3 DNA models (one checkpoint per distinct
architecture, skipping size and species variants); the full metric battery
(`alignment_matrix` over tsi/qsi/cka/mutual_knn, `similarity_correlation`,
`compare_embedding_matrices`, `knn_jaccard`, `compute_knn_overlap`,
`nearest_neighbors_table`) read with the sequence/knowledge split in mind;
within-vs-between similarity, per-class purity against a computed ceiling,
leiden and annotation enrichment; attention on Nucleotide Transformer and
GENA-LM mapped onto exon boundaries, with the attention-free wall and the
`has_attention` default both demonstrated; `benchmark_embeddings` against
targets chosen to behave differently; and a short SNPEmbedder example on a
synthetic contig that needs no hg38 download.

Two things the notebook measures rather than asserts. Enformer is excluded from
the sweep because `_preprocess_sequence` centre-pads to 196,608 bp, so a spliced
exon input is 99.15% blank -- it wants a genomic window, which is what
variant_effects.ipynb uses it for. And the all-spaces intersection costs 8 of 40
genes, concentrated in tubulin, because the lookup tables disagree about
coverage; the cost is reported per class with the attainable purity ceiling
beside it.

Generated by scripts/notebook_generators/genes_part1..6.py via build_genes.sh,
not hand-edited. Executed end to end with `jupyter nbconvert --execute`: 64/64
code cells pass, and outputs are stored as for the other tutorials. That run
resolved all 40 loci from Ensembl in 2,099s and hit two live exon timeouts
(STAT1, AURKA), both caught by the truncation fix in the preceding commit.

outputs/gene_exons.json is the resolved exon cache, committed because
regenerating it costs ~35 minutes against a flaky Ensembl REST.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…need

Following up the gene deep dive: every model the catalogue advertises should
either install from `uv pip install "embpy[cpu]"` / `"[gpu]"`, or say precisely
what else it needs. Several did neither. Every claim below was checked by
resolving it with `uv pip compile` or by reading installed distribution
metadata, not inferred.

**[ntv3] was unsatisfiable.** It pinned `transformers>=5.0.0` against the
`<5.0.0` cap in [cpu]/[gpu], so `uv pip install "embpy[ntv3]"` failed outright
and none of the five `ntv3_*` keys was installable by any documented route. The
wrapper needs no such version -- it loads through stock `AutoModelForMaskedLM`
-- so the pin is gone. The real barrier is access: the `InstaDeepAI/NTv3_*`
repositories are gated, which is now surfaced as an access error naming the
licence page and `huggingface-cli login` rather than a generic "Could not load".

**Flashzoi could not work at all.** `flashzoi_v0..v3` are Borzoi checkpoints
whose config sets `flashed = true` (verified against johahi/flashzoi-replicate-0
and -3; the plain borzoi configs omit the key), so borzoi-pytorch takes its
FlashAttention path and imports `flash_attn` with no eager fallback. `flash-attn`
appeared nowhere in pyproject. Added a `flashzoi` extra requesting
`borzoi-pytorch[flash]`, which is where the dependency is actually declared, and
a runtime gate that fails before the checkpoint download naming both the package
and the install line. The gate keys on the checkpoint rather than the wrapper
class because 8 of the 12 Borzoi keys need none of this -- a distinction
`WRAPPER_EXTRAS` (wrapper class -> extra) cannot express.

**Errors no longer discard what the wrapper knew.** `get_model` replaced each
wrapper's message with a uniform `DependencyError`, so a macOS user asking for
Evo was told to run `pip install embpy[evo]` -- a command that cannot resolve on
their machine, because evo-model pulls `triton` and triton publishes no macOS
wheels. `DependencyError` now takes `detail` and `get_model` forwards the
original text, so the install line and the constraint both survive. Evo names the
platform limit, Evo2 names `requires-python >=3.11,<3.13` and the dedicated-venv
recipe (no extra can fix an interpreter mismatch), Caduceus names `mamba_ssm`.

**Caduceus had no install advice.** It was missing from `WRAPPER_EXTRAS`, but
adding it was not enough: transformers only *logs* the mamba_ssm import failure
and then fails for an unrelated-looking reason, so the module name never reaches
the exception chain and `_missing_package_from_exception` could not recover it.
A `find_spec` precondition raising ImportError is what turns this into a typed
DependencyError naming `embpy[caduceus]`.

**A bad Evo layer index is no longer reported as a load failure.** The range
check ran inside `load`'s `try`, so the generic handler rewrote
"embedding_layer=999 is out of range" into "Could not load Evo". It now raises
ValueError after the block. This changes the exception type for direct wrapper
users; callers going through `get_model` still see `ModelLoadError`. The two
tests that asserted `RuntimeError` were pinning the masked behaviour and have
been updated with a note saying why.

**Corrected a claim about borzoi-pytorch.** Its transformers requirement flipped
direction between minor releases -- 0.4.4 wants `>=4.34.1,<4.51.0`, 0.5.1 wants
`>=4.57.6,<5.0.0`, and those do not overlap. Since the extra pins only `>=0.4.3`,
the borzoi version and the transformers version are silently co-determined:
install `[esm3]` and you get 0.4.x, leave it out and you get 0.5.x. Documented
rather than pinned, so the choice stays the caller's.

README gains a resolution-verified status column, dedicated-venv recipes for the
three cases that genuinely cannot share an environment (evo2 by interpreter,
flashzoi by build, boltz by numpy), and the note that ESM-C/ESM3 does *not* need
one -- `uv pip install esm --no-deps` avoids the transformers cap entirely, and
with it the silent borzoi swap.

Also recorded, because it shaped the fix: `[tool.uv] conflicts` does not help
here. Declaring two extras as conflicting still let a satisfiable downgrade
through silently in a direct test (packaging 26.3 -> 23.2), because it is a
lock-time feature, not a guard against legal-but-degrading resolutions.

Tests: a new consistency suite that fails if any extra contradicts [cpu]/[gpu]
-- verified by reintroducing the ntv3 pin -- plus checks that the supported
installs never pull flash-attn/evo2/esm/boltz/mamba-ssm and that the flashzoi
gate names its package and flag. Also widened the wrapper-name scan in
test_dependency_hints, which matched only `(FooWrapper` and so silently skipped
every multi-line registry entry, Caduceus among them.

1924 passed. The remaining 3 failures and 5 errors are pre-existing environment
issues (leidenalg absent from the dev env; a test FASTA samtools rejects) and
are untouched by this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… it exposed

MoleculeAnnotator reached three ChEMBL endpoints -- molecule, activity,
mechanism -- which covers structure, potency and a mechanism string. ChEMBL_37
carries considerably more that a perturbation screen wants to know about its
compounds, so add ChEMBLAnnotator (resources/molecule/chembl.py) covering
drug, drug_indication, drug_warning, atc_class, molecule_synonyms, metabolism,
molecule_form, target/target_component/protein_classification, similarity, and
the full activity field set. Plus derived per-target potency profiles and a
selectivity summary.

Wired in three ways: MoleculeAnnotator gains the source groups "drug",
"target_profile", "metabolism" and "analogs" (the first three included in
sources="all"), annotate_adata writes 38 drug_* obs columns alongside the
existing mol_* ones, and tl.annotate_drug_perturbations is the AnnData-level
entry point -- the compound-centric counterpart to tl.annotate_drugs, which
flags genes rather than describing compounds.

Building it surfaced five bugs, every one a silent wrong answer rather than an
error, each verified against the live API:

1. get_mechanism_of_action returned [] for any drug whose mechanism ChEMBL
   registers against a salt form. It filtered `mechanism` on
   molecule_chembl_id, but drug-level rows hang off the PARENT molecule:
   mechanism?molecule_chembl_id=CHEMBL941 (imatinib) returns zero rows because
   the mechanism belongs to imatinib mesylate and names CHEMBL941 only as its
   parent. Drug-level queries now resolve the hierarchy and filter on
   parent_molecule_chembl_id -- for imatinib, the difference between 0 and 4
   mechanisms, and between 52 and 134 indication rows. Indistinguishable from
   "this compound has no curated mechanism".

2. The same method always returned target_name as "". It read target_name off
   the `mechanism` payload, which carries only target_chembl_id.

3. _resolve_to_smiles resolved every NAME to None, so annotate() reported
   "Could not resolve identifier to SMILES" and every mol_* structural column
   came back empty for name-based input. PubChem renamed its SMILES properties
   without changing the request vocabulary: a CanonicalSMILES request answers
   under ConnectivitySMILES, an IsomericSMILES request under SMILES.

4. DrugResolver._extract_smiles did not know the current SMILES key, so the
   direct name lookup always failed and fell through to the slower CID path
   (source="pubchem_cid" where it should report "pubchem_name").

5. Molecule embeddings could not be attached back to an AnnData indexed by a
   non-canonical SMILES. embed() canonicalises molecule ids on the way in, but
   build_aliases had no molecule branch, so the form the caller passed was
   never registered as an alias; to_anndata then re-indexed by the target's own
   .obs_names and raised "N target .obs_names have no embedding" for molecules
   that had embedded fine. 01_embed_any_model.ipynb pre-canonicalised with
   RDKit to work around this; it no longer needs to, and now passes Kekule
   caffeine on purpose to show the conversion happening.

Counts drawn from a capped fetch carry an *_at_limit companion column and the
caps land in .uns["chembl_annotation_limits"], the contract GeneAnnotator
already uses.

Tests: 117 new in test_chembl_annotator.py, three in test_to_anndata_attach.py.
Several assert on the REQUEST rather than the response, because ChEMBL ignores
filter parameters it does not recognise and returns the unfiltered collection
-- a misspelled field name yields a plausible response covering the whole
database. test_molecule_annotator.py's cross-reference test was rewritten to
dispatch by URL: it had been passing on a coincidence in call ordering.

244 pass across tests/embpy/io and the three molecule suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l missing

Groundwork for turning small_molecules.ipynb into a deep dive of the same depth
as genes.ipynb (128 cells) and proteins.ipynb (89). Committed incomplete so the
build spec and the finished parts are not lost.

Present: parts 1 (intro, panel, catalogue), 3 (the three numbered comparison
sections), 4 (biology tracking, unsupervised structure), 6_attn (ChemBERTa
attention, hand-written and verified to emit 22 cells) and 6 (downstream
benchmark, molecule-only sections, save, "What we found").

MISSING, so build_small_molecules.sh cannot assemble a notebook yet:
- part 2: compound resolution, the tokeniser audit, the embedding sweep
- part 5: annotation -- the ChEMBL clinical depth, the part the notebook
  exists to justify

Also owed: 6_attn is meant to be part 6 and the current part 6 becomes part 7,
then the build loop extends to 7. SMALL_MOLECULES_WIP.md carries the exact
commands and the state of play.

small_molecules_spec.md is the build spec: house pattern extracted verbatim
from the genes generators, the 5x8 panel chosen so structural and mechanistic
similarity disagree, the model roster, the inter-part variable contract (the
notebook runs in one kernel), and the section-by-section outline.

Environment facts recorded there rather than rediscovered later:
- `uv sync` fails on this project -- embpy[esm3] and embpy[helical] pin
  incompatible transformers. Use pixi, or a venv built with --no-deps -e .
- BioEmbedder() needs torch+transformers even for the RDKit fingerprints,
  because embedder_registry/flat.py imports every modality's registry
- molformer_base cannot load against transformers 4.48.1: its
  trust_remote_code module imports transformers.masking_utils, which arrived
  later. Two re-wraps hide the cause, so part 6 walks the __cause__ chain and
  treats it as an honest-failure section rather than pretending it works
- chemberta2MLM is 768-wide with 12 blocks, not 384 -- the two ChemBERTa
  checkpoints differ in width and tokenisation, not just training objective
- extract_attention takes tokenised input_ids, not a SMILES string

Left open: summarize_selectivity's defaults report imatinib's primary target as
ERBB2 off a single 10.22 assay, against 43 ABL1 measurements with a ~7.9
median. The min_measurements and rank_by parameters exist and the failure mode
is documented, but the right default is a judgement call not yet made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…output

canonicalize_smiles runs a five-step rdMolStandardize pipeline, and each step
announces itself to RDKit's log -- "Initializing MetalDisconnector", "Running
Normalizer", and so on, about eleven lines per molecule. Whether they surface
depends on ambient RDKit log routing, which differs between a plain interpreter
and a Jupyter kernel: locally rdApp.info is disabled and nothing appears, but in
a notebook the chatter buries the cell's actual output. Canonicalising three
molecules produced 33 lines of it.

This became visible in 01_embed_any_model.ipynb only because that notebook now
routes through embpy's canonicaliser rather than calling Chem.MolToSmiles
itself, so the standardiser runs where it previously did not.

None of the output is actionable -- an unparseable SMILES is already caught by
the MolFromSmiles guard above, and these lines report progress rather than
problems. Wrap just the standardiser calls in rdBase.BlockLogs(), which is
scoped and restores the previous state on exit, so a caller who deliberately
enabled RDKit logging keeps it.

Verified by forcing rdApp.info on (the state the notebook kernel was in): the
eleven-line block per molecule disappears and the ambient log state is intact
afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ooling

Four fixes, all found by running the tutorial notebooks rather than by reading
them, and all the same shape: a network or bookkeeping failure presented as a
fact about the data.

**UniProt is not retried.** `protein/resolver.py` turned every exception into
`None`, and every caller above it reads `None` as "this protein does not
exist". Ask for sixteen FASTAs back to back and UniProt resets a handful while
the rest succeed, so a throttled request was indistinguishable from an absent
entry. All four HTTP call sites now go through a `retry_with_backoff` helper
that treats 4xx as permanent and 5xx / timeouts / resets as transient -- the
gene resolver has done this for Ensembl and MyGene since it was written; this
brings the protein side to parity. Concretely: `03_compare_embeddings.ipynb`
died with `ValueError: 6 target .obs_names have no embedding` after six of
sixteen symbols dropped. It now runs unchanged, and the six resolve.

**Sequences were not cached.** Accessions were, sequences were not, so
anything asking for the same protein twice paid twice. The layer sweep in
notebook 03 made 112 UniProt requests for 16 distinct proteins -- which is what
earned the throttling that caused the failure above. Now 16, and a repeat pass
is free (measured: 4.53 s to 0.00 s).

**A partial batch was silent.** `get_canonical_sequences_batch` skipped
whatever failed, and callers iterate what came back rather than what they asked
for, so the gap surfaced much later as a re-index error pointing at the
exporter rather than at the network. It now logs a counted warning naming the
unresolved ids, the same contract `io._canon.canonicalize` already uses.

**Static tables claimed a pooling they never applied.** `genept` is a
precomputed lookup table: no forward pass, no tokens, nothing to pool. But
`_embed_static_hf_to_result` recorded the caller's `pooling_strategy`, which
defaults to "mean" whether or not it was asked for, so provenance reported
`pooling: "mean"` and `_default_key` advertised it in the key --
`X_emb__gene__genept__pool_mean`. Now `pooling=None` (the convention the cell
path already uses) and the key is `X_emb__gene__genept`.

Notebooks, alongside:

- `02_output_contract.ipynb`: the provenance section was one cell printing that
  a key exists. It now demonstrates what provenance is actually for -- pass a
  stale HGNC symbol (`AARS`, `EPRS`) plus one that does not resolve, embed with
  mean pooling, then again with cls into the same object, and read the record
  back. Two 384-dim matrices over the same three genes from the same weights,
  cosine 0.369 apart, distinguishable only by the key and the provenance block;
  `n_requested_inputs` 4 against `n_successfully_embedded_entities` 3 accounts
  for the dropped gene; the round trip now checks the recipe survives rather
  than that a shape does. Executed end to end, 10/10 code cells.
- `01_embed_any_model.ipynb`: dropped a NaN-count line that could not fail --
  `missing` defaults to "error" there, so it could only ever print 3/3.
- `scib_validation.ipynb`: `models = [..., "geneformer", ...]` is not a
  registry key. Geneformer ships as nine checkpoints, so the availability guard
  reported "not available in this environment" -- a different and misleading
  claim from "that key does not exist". Now `geneformer_v2_12L`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`state` (STATE-SE) and `stack` are listed by `list_available_models()` and
could not be run through the public API on any platform. Both wrappers take
`checkpoint` on `__init__` and raise from `load()` without one --
`ValueError("Either checkpoint or model_folder must be provided.")` and
`ValueError("checkpoint path is required for StackWrapper.")` -- and there was
no route to supply it. `get_singlecell_wrapper` has always accepted `**kwargs`
and forwarded them to the constructor, but `embed_cells` had no parameter for
them and `_get_or_load_singlecell_wrapper` called the factory with
`batch_size` alone. `embed(entity_type="cell", checkpoint=...)` hits the same
wall, since it forwards into that same keyword-only signature.

    embedder.embed_cells(
        adata, models=["state"],
        model_kwargs={"state": {"checkpoint": "/path/se600m.ckpt"}},
    )

The kwargs are keyed by model, because `models=` takes a list and two models in
one call need different arguments. They also join the wrapper cache key, which
was previously `(model_key, device_str)` alone: without that, asking for a
second checkpoint of the same model would have returned the first one already
loaded -- a wrong answer that looks like a working one. Calls that pass no
kwargs keep the old two-element key, so nothing about existing cache behaviour
changes.

Not tested end to end here, and worth saying so plainly: `arc-state` and
`arc-stack` are declared linux-64 only in pixi.toml, so neither backend
installs on macOS. TestSingleCellModelKwargs covers the plumbing -- kwargs
reach the constructor, a no-kwargs call is unchanged, two checkpoints do not
share a cache entry, the same checkpoint does -- not a forward pass. The
remaining gap needs a Linux box with the backends installed.

153 pass across the three single-cell test files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
full Python 3.12 failed with:

  assert 'flash_attn' in 'borzoi_pytorch not installed; cannot load BorzoiWrapper.'

The test asserts that a missing flash_attn produces an error naming the package and
the fix. But the flash_attn gate sits *after* the borzoi_pytorch check in
BorzoiWrapper.load, and the full CI env has neither package -- so load() fails at the
earlier gate and the assertion sees the wrong message. The test premise only holds
when borzoi_pytorch is present.

Added pytest.importorskip("borzoi_pytorch") alongside the existing torch guard, so it
skips where the gate cannot be reached and still runs where it can (verified locally:
borzoi installed, flash_attn absent, both tests pass).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ecuted clean

Completes the notebook the last commit left half-built. Parts 2 and 5 are new,
the attention part becomes part 6 and downstream/close becomes part 7, and the
whole thing runs top to bottom: 79/79 code cells, no errors.

**Part 2 -- resolution and the sweep.** Resolves forty compound names through
DrugResolver, recording which service answered each one, then canonicalises and
re-indexes on canonical SMILES. That re-index is the load-bearing step: `embed()`
canonicalises the SMILES it is given and re-joins against `obs_names`, so an
AnnData indexed by compound *names* would NaN-fill all forty rows and report it
as model behaviour. Audits ChemBERTa token lengths against the 512 clamp before
the sweep, because `embed_batch` silently drops anything longer. Then one
`embed` per model with `missing="nan"`, `SWEEP` derived from what actually
worked rather than from the roster, and `mol_space` / `dense_space` split so no
metric ever compares two models on different compounds.

**Part 5 -- the clinical record.** The section the notebook exists for, and the
first real exercise of `tl.annotate_drug_perturbations`. Development status,
indications with the phase reached per indication, the safety record, WHO ATC
crosstabbed against the hand-built families, mechanism, targets as gene symbols,
selectivity, metabolism. Then re-scores every embedding's kNN purity against
`drug_atc_level1` and `drug_target_class` -- labels nobody here chose -- rather
than only against the panel's own `family` column.

Two traps get demonstrated rather than described. The notebook queries ChEMBL's
mechanism table both ways with raw `requests` so the reader sees
`molecule_chembl_id=CHEMBL941` return nothing while
`parent_molecule_chembl_id=CHEMBL941` returns four. And it shows
`summarize_selectivity` under three defensible settings giving three different
primary targets for imatinib -- ERBB2 off a single 10.22 assay by default, DDR1
at `min_measurements=5` -- none of them the ABL1 it is prescribed against.

Verified from the executed run: 40/40 compounds resolved, 40/40 matched in
ChEMBL, `dense_space` keeps all forty, rofecoxib surfaces as Withdrawn /
cardiotoxicity / Worldwide / 2004, aspirin to salicylic acid via esterases,
TSI(morgan binary, morgan count) = 0.772.

Two checks worth keeping rather than rediscovering. A static pass over the
assembled notebook for names read before any cell defines them caught two real
breaks (`dense_space` used a section early, `MIN_ROWS` borrowed from a later
part). A pass validating every embpy call's keywords against the real signature
caught `knn_label_purity(show=...)`, which does not exist -- that one had
already cost a 30-minute run, dying at cell 56.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
embpy's annotation surface had no tutorial -- the entry points were reachable
from the API reference and exercised in the modality deep dives, but nothing
introduced them or said what they are for. Now that the ChEMBL clinical record
is in, that gap is worth closing.

`05_annotate_entities.ipynb`, 25 cells, executed clean:

- **Requirements first.** Annotation is network-bound, not compute-bound: no
  weights, no GPU, just APIs and patience. The section says which entry point
  needs which extra and gives the honest per-compound cost, so the slow cells
  are not a surprise.
- **Molecules** -- `tl.annotate_molecules` for the local RDKit half.
- **The clinical record** -- `tl.annotate_drug_perturbations` for the 38
  `drug_*` columns, then the part no structural model can recover: rofecoxib's
  withdrawal, read out of `.uns` with its class, country and year.
- **Targets as genes, not accessions**, including the honest caveat that
  `summarize_selectivity` gives three different primary targets for imatinib
  under three defensible settings.
- **Genes and proteins** -- `gene_*` and `prot_*`, and why a capped count with
  an `*_at_limit` flag is not a measurement.
- **Putting annotations to work** -- the actual argument for the layer: an
  unlabelled embedding cannot be evaluated, and an annotation is a label your
  analysis did not choose.

Wired into `docs/index.md` (slot 05 was free) and the README tutorial path.

Two more RDKit logging fixes, both found by reading this notebook's output:

- `_resolve_to_smiles` and `_resolve_pubchem_cid` speculatively parse the
  identifier as a SMILES to decide whether it is a structure or a name. For a
  name that failure is the expected path, but RDKit logged five "SMILES Parse
  Error" lines per compound over the caller's output.
- `ChEMBLAnnotator._classify` did the same probe but called
  `RDLogger.DisableLog("rdApp.*")` -- globally and permanently. One identifier
  lookup silenced RDKit for the rest of the caller's session. Both are now
  scoped `rdBase.BlockLogs()`, verified to leave the ambient log state intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o run

`state` was the one key `list_available_models()` advertised that `load()`
categorically would not run: it raised

    ValueError: Either checkpoint or model_folder must be provided.

while the SE-600M weights sat public on the Hub. Every other single-cell
wrapper downloads on first use, so this was an inconsistency rather than a
policy -- and combined with `embed_cells` having had no way to pass a
checkpoint (fixed in e419239), the model was unreachable by any route.

When neither path is given, `load()` now fetches the published release and
picks the config and protein embeddings out of it. Only the three files needed
to run are pulled -- `config.yaml`, `protein_embeddings.pt` and
`se600m_epoch16.ckpt`, about 12 GB. The repo also ships an `epoch4` checkpoint
and two safetensors variants; taking the whole thing would be ~29 GB for no
benefit. `huggingface_hub` caches, so it is paid once, and the log line says
what is being fetched and how big before the wait starts rather than after.

Naming `checkpoint=` or `model_folder=` still wins and never triggers a
download -- guarded by a test, because silently pulling 12 GB for a caller who
supplied a local path would be a worse bug than the one this fixes.

The real fetch is not exercised in CI for the obvious reason; the tests cover
the wiring -- which files are requested, that the oversized variants are not,
that an explicit path short-circuits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every one of the thirteen registry filenames was fabricated. Each carried a
`_rna.h5ad` suffix and an invented GSE accession -- registry
`DatlingerBock2017_GSE92872_rna.h5ad` against the record's actual
`DatlingerBock2017.h5ad`, registry `ReplogleWeissman2022_RPE1_rna.h5ad` against
`ReplogleWeissman2022_rpe1.h5ad`, and so on for all thirteen. Not one matched a
file in Zenodo record 7041849, so `load_scperturb` raised `HTTPError: 404` for
every dataset it advertised and had never once succeeded.

Filenames are now the record's real ones, checked against the live Zenodo API
for all fourteen entries.

Two entries named datasets that are not in the record at all and are removed:
`PapaleandrouSchraivogel2019` duplicated `SchraivogelSteinmetz2020` under a
garbled author name (same TAP-seq DOI), and `UrsuHein2022` is not in this
record. `TianLuo2019` is renamed `TianKampmann2019` -- there is no Tian/Luo
scPerturb dataset, and the entry's own reference
(10.1016/j.neuron.2019.07.014) is the Tian *Kampmann* Neuron paper, so the key
was garbled rather than the citation.

Three real files the registry never exposed are added:
`ReplogleWeissman2022_K562_essential` (much smaller than the genome-wide screen,
so the better default), `ShifrutMarson2018`, and
`SrivatsanTrapnell2020_sciplex3` -- a 188-compound screen, which matters because
the registry was otherwise entirely genetic and there was no chemical
perturbation dataset to pair with the ChEMBL annotation layer.

The 404 itself was most of the problem: it named a URL, so it read as Zenodo
being down rather than as embpy asking for a file that has never existed. A 404
now raises `FileNotFoundError` listing the closest real filenames and stating
plainly that it is a stale registry entry, not a network fault. Non-404 errors
are re-raised untouched so a genuine outage still looks like one.

Verified by actually downloading: `load_scperturb("DatlingerBock2017")` returns
(5905, 36722) with 97 perturbations, a `control` level, and raw counts.

`tests/embpy/pp/test_scperturb_registry.py` pins the registry shape offline --
no `_rna.h5ad` suffixes, no phantom keys, a chemical screen present -- and adds
one opt-in network test (`EMBPY_TEST_NETWORK=1`) that checks every filename
against the live record, which is the check that would have caught this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes `pp.load_scperturb`, `pp.list_scperturb_datasets`, `pp.scperturb_info`
and `ScPerturbDatasetCard`.

Downloading third-party perturbation datasets is not this package's job.
The module was a hand-curated mirror of someone else's Zenodo record -- a
dataset index that drifts independently of embpy and has to be tracked by hand,
which is exactly how all thirteen of its filenames came to be wrong. Fixing
them (787226e) made it work, but maintaining a dataset catalogue alongside an
embedding library is ongoing cost for something a user can do in one line:
read the h5ad and pass it to `embed` / `embed_cells`.

Nothing in embpy depended on it. The only other references were three
autosummary entries in docs/api.md and one passing prose mention in
cells.ipynb, both removed. The `"TP53|MYC"` note in
`resources/gene/control.py` stays -- it documents a pipe-delimited identifier
format that exists in the wild regardless of whether we ship a loader for it.

733 pass; the single error in `test_local_genome.py` is the pre-existing pysam
fixture failure, unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cell_eval` forwarded every `**kwargs` to `MetricsEvaluator.__init__` and called
`compute(profile=..., write_csv=False)` with nothing else. But in cell-eval
0.8.2 `metric_configs` and `skip_metrics` are `compute` arguments, so asking for
them raised

    MetricsEvaluator.__init__() got an unexpected keyword argument
    'metric_configs'

and `metric_configs` is the only route to `embed_key`. Without it the ten
`ANNDATA_PAIR` metrics -- the ones that *can* run on `.obsm` -- silently fell
back to `.X`. For an embedding comparison that is not a missing feature, it is
the wrong matrix reported as if it were the right one.

Both are now named parameters routed to `compute`, while `**kwargs` continues
to reach the constructor, which is where `skip_de` belongs. Tests assert the
split in both directions, because putting either one on the wrong side fails
silently rather than loudly.

Found by running it: the notebook 04 probe died on this call, and cell-eval
0.7.2 (which the API survey was based on) accepted `metric_configs` on the
constructor, so the change is a version drift rather than a misreading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old version built a 16-gene AnnData with `target = rng.normal(size=16)`,
trained ridge and kNN probes against it, and presented `res["r2"].max()` as a
model ranking. The target was noise, so the leaderboard ranked sampling
variance, and the surrounding prose ("higher R-squared means the embedding
carries more information about your target") invited the reader to believe it.

Now it does what it claims: embed the same cells with five single-cell models
(`pca`, `scvi`, `scgpt`, `geneformer_v2_12L`, `state`), then score those
embeddings with two metric families and plot both.

- **scIB** (`tl.compute_scib_metrics`) for structure preservation, against
  `louvain` labels taken from `pbmc3k_processed` rather than from clustering the
  embeddings -- scoring an embedding against its own clusters measures
  self-consistency.
- **cell-eval** (`tl.run_cell_eval`, `profile="anndata"`) for pairwise
  agreement, with `embed_key` on every `ANNDATA_PAIR` metric so the numbers
  describe the embedding rather than `.X`.

`pca` is in the roster deliberately. It is the floor a foundation model has to
clear, and a transformer that ties with truncated SVD has told you something
about your data.

Three things the notebook is explicit about, because each is a way to be
quietly wrong:

- `embed_cells` records per-model failures in `.uns` and returns a normal
  AnnData, so the notebook reads that metadata instead of trusting the return
  value. A missing `.obsm` key is otherwise the only symptom.
- cell-eval needs identical label sets on both sides, so the pair is split
  *within* each cell type and types too small to appear on both are dropped and
  named. A plain random split strands the rarest type and fails forty lines
  later.
- `discrimination_score_l1` hardcodes `embed_key = None` upstream and is
  skipped, rather than leaving one expression-space column in an
  embedding-space table.

The Requirements section and the README both carry the environment recipe.
No resolver-satisfiable environment holds helical, arc-state and cell-eval at
once -- helical wants `numpy<2.3` and `transformers<=4.51.3`, arc-state wants
`transformers>=4.52.3`, cell-eval's pdex wants `numpy>=2.4.2` -- but the
helical ceilings are stale, so `--no-deps` plus its real runtime deps works.
Verified by running: all five models embed.

Cell outputs are not in this commit. The execution run was on the cluster and
the VPN dropped before it could be retrieved; the notebook's cells were
verified by a smoke run of the same code with a `pca`-only roster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the cell outputs the previous commit could not include, from a full run on
the cluster in `.venv-sc` (job 39795042, ~35 min on CPU). 11/11 code cells,
zero errors, three figures.

All five models embedded, which is the claim the Requirements section makes and
so worth having on the record:

    pca                 50    X_pca
    scvi                30    X_scvi
    scgpt              512    X_scgpt
    geneformer_v2_12L  512    X_geneformer_v2_12L
    state             2058    X_state

scIB, ranked by bio_conservation:

    X_geneformer_v2_12L  0.820
    X_state              0.770
    X_pca                0.752
    X_scvi               0.741
    X_scgpt              0.729

Geneformer clears the PCA floor by 0.068 and scGPT falls below it, which is the
kind of result the notebook exists to produce rather than assume -- and it is
why `pca` is in the roster instead of being taken for granted as the weakest
entry.

cell-eval, on the embedding rather than `.X` for all nine configurable
ANNDATA_PAIR metrics, separates the spaces differently again: `pearson_delta`
puts geneformer (0.961) and scgpt (0.954) top with `state` last (0.705), while
`discrimination_score_l2` is 1.0 everywhere except `state` (0.972). Two
families, two orderings, which is the point of running both.

The pair construction reported itself as designed: 200 real / 198 pred cells,
`CD4 T cells` as control, `Megakaryocytes` dropped for having too few cells to
appear on both sides, 7 labels retained.

One rough edge left as-is: dependency log noise (embpy's optional-extra
warnings, a scib leiden FutureWarning, a polars DeprecationWarning) sits in
several outputs. It is consistent with the other notebooks in the repo and
re-running to silence it would cost another 35 minutes of cluster time for a
cosmetic gain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compute_scib_metrics listed isolated_label_asw in _BIO_METRICS and computed it
unconditionally, but the scib call needs the batch covariate -- scIB identifies
an isolated label by how few batches it appears in. Called with batch_key=None
it raises "[nan] not in index" inside scib, which _safe_metric turned into a
dead column plus one warning per embedding.

04_benchmark_models.ipynb shows the effect: all five models scored NaN there,
so bio_conservation averaged four metrics while appearing to average five.

Gate the metric on batch_key and say so in the docstring. Aggregate values are
unchanged (the mean already skipped NaN); the column is now absent rather than
empty, which is the honest report.

Add two tests pinning the report's column set against a stubbed scib, so
neither the gate nor the 0.6/0.4 aggregate weighting can regress without an
optional dependency installed. The heavy path is skipped in the default CI job,
which is why the shape of the frame was never asserted anywhere and this
shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight cells, four of them code, zero stored outputs -- it had never been
executed. Its content is a skeleton (embed, compute_scib_metrics, print the
winner) that 04_benchmark_models.ipynb already does on real data across five
models, and that the cells deep dive covers with a batch covariate, which is
the half neither of the others reaches.

It was referenced in three places, all fixed rather than left dangling:
README's notebook list, the docs/index.md toctree (which would otherwise have
broken the docs build), and nb04's closing "Next:" link. That last one is
repointed at cells.ipynb in both the generator and the stored notebook, so a
rebuild does not reintroduce it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three fixes to the generator tooling.

assemble.py now parses every code cell before writing the notebook. A generator
that emits unparseable code otherwise assembles fine and fails on execution
minutes later, on a cluster, pointing at a notebook cell rather than the part
file that produced it. Nested triple quotes inside the r""" builders are the
recurring cause and hit this build too.

build_cells.sh defaults PY to `uv run --no-project python` rather than a pixi
interpreter. The generators import nothing from embpy, and the project
environment cannot be resolved at all -- embpy[esm3] and embpy[helical] have
mutually unsatisfiable pins -- so --no-project is the only default that works
from a clean checkout.

Remove cells_part1..5.json. They were tracked build artifacts of the *proteins*
notebook, misnamed: they sum to exactly 89 cells, which is proteins.ipynb. The
README recipe that produced them under a cells_ name is corrected, so building
proteins no longer clobbers files named for another notebook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cells.ipynb is 19 cells and simulates 120 cells over 24 genes. That is not a
matter of scale: scGPT, Geneformer, UCE, STATE and STACK tokenise a cell by the
rank order of thousands of genes, so a 24-gene input yields near-constant
embeddings and every comparison in it compares rounding error. It also raises
twice, so a reader without the optional stack gets a traceback rather than a
notebook.

The spec targets 134 cells across six parts and pins the design decisions:

- Two datasets, because the metric families need different experiments. scIB is
  an atlas-integration benchmark, so a perturbation dataset is the wrong
  substrate -- scoring a model for erasing a treatment difference rewards it for
  deleting the experiment. cell-eval is the mirror: it scores agreement per
  perturbation, so an atlas gives it nothing. Norman 2019 is verified for the
  second (237 levels, control level "control", every level >= 50 cells, counts
  in layers["counts"] rather than .X); the atlas slot is left open behind the
  contract.
- A five-constant swap-in contract, asserted rather than trusted, including
  that counts are integral -- scvi declares input_layer="counts" and Geneformer
  tokenises by rank, so a pre-normalised matrix fails silently, not loudly.
- Three tiers in the roster, because the comparison is not apples to apples:
  scvi accepts batch_key and forwards it to setup_anndata, and scanvi raises
  without labels_key, so it necessarily sees the labels bio-conservation scores
  it on. X_scvi against X_scvi_batch is the one controlled experiment here.
- Fabricated covariates measured and rejected: injected batch effects are
  either invisible (silhouette +0.034) or destroy the biology (-0.010), because
  normalize_total removes a library-size shift by construction and an effect on
  18 of 32,738 genes does not survive HVG selection.

Parts 1-3 cover the contract and catalogue; per-model preprocessing with a
vocabulary-overlap audit and the eight-model sweep; and cross-model agreement.
No raise gates -- a missing backend is a reported row in FAILURES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
For the cells notebook, evaluation is scIB and cell-eval and nothing else.
embpy's own comparison metrics -- tl.alignment_matrix with tsi/qsi/linear_cka/
mutual_knn, tl.knn_jaccard, pl.knn_label_purity, tl.leiden plus
tl.cluster_annotation_enrichment -- are deliberately not used here. For cells
there are community-standard answers to both questions, and running a parallel
bespoke suite beside them invites averaging numbers that are not on the same
scale. Those tools earn their place in genes/proteins/small_molecules, where no
standard exists.

Consequences: the cross-model agreement part is removed outright (it was
nothing but those metrics), the scIB part loses its purity and re-clustering
tail (scIB already optimises a Leiden clustering against the label to compute
NMI and ARI), and the notebook has two numbered sections rather than three.
Parts renumbered 1-5; target drops to 105-125 cells.

Part 4 is cell-eval on Norman 2019. Three details verified against nb04's
executed code rather than assumed:

- metrics_registry.list_metrics(MetricType.ANNDATA_PAIR) takes the type as an
  argument.
- discrimination_score_l1 hardcodes embed_key = None upstream, so it cannot be
  pointed at an embedding at all and is skipped explicitly. Leaving it in puts
  one expression-space column in an embedding-space table.
- profile="anndata" with skip_de=True is the combination that scores every
  pair metric on the embedding.

It also subsamples within perturbation rather than across the object -- a flat
sample of 4,000 from 111,255 cells would destroy the level count that makes the
dataset worth using -- and correlates each metric against an independently
computed effect size, because a metric that scores a near-null perturbation as
highly as a strong one is not measuring the perturbation.

Part 5 covers attention (geneformer natively, tahoe via pre-hook), the two
inherited has_attention flags that cannot be honoured, decode round-trip
correlation, gene annotation onto .var, and the artifact round trip.

Also fix a bug parts 4 and 5 shared: `ad` was bound only inside the loader
functions, so part 5's module-level ad.AnnData would NameError. anndata is now
imported in part 1's preamble and the loaders no longer shadow it. The
cross-cell checker that missed this is rewritten to model scope properly --
names bound in a function body no longer count as available to later cells --
and build_cells.sh runs it before assembling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…notebook

The atlas slot is filled: the scIB human pancreas benchmark (Luecken et al.
2022), figshare file 46763269. Downloaded and inspected rather than assumed.
16,382 cells x 19,093 genes, 14 islet cell types in `celltype`, nine batches
over six protocols in `tech`.

Two things had to be checked before it could be used, and both changed the code.

The counts. `.X` is log-normalised (max 13.0) and `.layers["counts"]` holds
counts, but only 57% of its values are integral: the droplet runs contribute
integer UMIs while the plate-based studies contribute estimated counts from
transcript quantification, which are fractional by construction (1.002, 2.008,
4.032). scvi-tools requires integers, so the loader rounds and prints that it
did. The figshare description calls this the "full raw dataset", which is true
of its provenance and not of its dtype -- part 1's assertion is what establishes
it, not the filename. The matrices are also stored dense, not sparse, so a
`nonzero_values` accessor replaces direct `.data` reads: on a dense array
`.data` is the memory buffer, and reading it produced a plausible-looking
statistic over reinterpreted bytes rather than an error.

The verdict statistic, which was wrong. Part 1 judged whether a dataset is a
real integration benchmark from the *global* batch silhouette. On this atlas
that is -0.086 while the median within-label figure is +0.180 -- so the old
heuristic would have called the best available dataset weak. Globally a batch is
never a coherent cluster because cell type dominates the geometry; the
within-label figure asks the question that matters, holding biology fixed. The
verdict now uses the within-label median, reports the global one beside it, and
prints an explanation when the two disagree.

Subsampling is now stratified within label with a floor, because a flat sample
of 1,500 from 16,382 left schwann and t_cell with one cell each -- and
batch-restricted rare labels are exactly what isolated_label_asw scores.
Stratifying preserved all 14 labels, cut nestedness from 0.976 back to 0.725
(the full dataset's value is 0.712), and raised the biology silhouette from
+0.117 to +0.272.

Part 3 is the scIB section: the nine-metric battery with a batch covariate, a
check of which metrics actually ran rather than a table with silent holes, a
column-set diff against a no-batch-key call to show why bio_conservation here is
a mean of five metrics where nb04's was a mean of four, the computed verdict
scoring part 1's prediction, the three-tier disclosure with X_scvi against
X_scvi_batch as the controlled comparison, and a rank-correlation of the nine
metrics against each other.

cells.ipynb is assembled at 95 cells (44 code, 51 markdown, 1,129 code lines,
7,174 words) replacing the 19-cell version. Not yet executed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scIB's silhouette and LISI metrics are quadratic in cell count and the roster is
ten embeddings wide, so N_CELLS dominates runtime more than anything else here.
At 3,000 cells that projected to roughly ten hours for the scIB cell alone,
extrapolating from 04_benchmark_models.ipynb's 400-cell run.

Drop to 1,400 cells (floor 40 per label, which still keeps all fourteen) and
coarsen the Leiden resolution sweep from 20 steps to 10 -- at ten embeddings the
default is 200 clusterings, and ten resolutions finds essentially the same
optimum. Both changes are stated in the notebook, along with the consequence:
the metrics are noisier at this size, so read the spreads rather than the third
decimal place. That is the same caveat nb04 makes about its own 400 cells.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generators README covered the proteins, genes and small-molecules builds but
not the cells one. Add it, along with the two things about it that are easy to
get wrong.

Datasets must be staged into docs/notebooks/data/ by hand, because pertpy is
deliberately absent from .venv-sc -- adding it risks moving numpy or scanpy in an
environment that took real work to resolve against helical, arc-state and
cell-eval. The two curl commands are recorded so this is reproducible.

Execution must target an H100. The interactive_gpu_p nodes are V100s (sm_70) and
.venv-sc's torch is a CUDA 13 build with no kernels for them, so
torch.cuda.is_available() returns True and kernel launches fail afterwards --
which is a far worse failure than an honest refusal, and cost time in this
session before it was diagnosed.

Also document the two build guards: assemble.py's ast.parse gate, and
check_contract.py, which reports any name a cell reads that no earlier cell bound
at module level. Function-local bindings do not carry to later cells, and that
class of bug assembles cleanly and NameErrors on the cluster.

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.

1 participant