Skip to content

a cell says how much of its yield was ribosomal - #491

Merged
lhqing merged 4 commits into
mainfrom
feat/471-rrna-fraction
Aug 22, 2026
Merged

lhqing merged 4 commits into
mainfrom
feat/471-rrna-fraction

Conversation

@lhqing

@lhqing lhqing commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

A cell can now say how much of its yield was ribosomal, and that number is the whole of what
seqforge owes a consumer about rRNA sitting in the expression matrices.

What lands

Two obs columns on the plate object, written by rule umi_count:

  • n_umis_rrna — deduplicated molecules in ribosomal genes.
  • rrna_fraction — that count over n_umis, nan for a cell with no molecules.

Both counted over umi_combined, the same population n_umis, genes_detected and the saturation
are all read off, so numerator and denominator are one number rather than two derivations that agree
by coincidence. The page carries the share beside the saturation, ungraded.

The gene set is liulab-genome's curated rRNA category for the annotation the plate was actually
counted against, asked for in the CLI verb — the counter is handed gene ids and never imports
genome, exactly as it is handed a resolved database and never an assembly id. seqforge owns no
list of ribosomal genes and does not subset the one it is given: the category is deliberately
inclusive, pseudogene copies and mitochondrial rRNA included, which on the worm plate is the 53.1%
end of the 49.5/53.1 range #427 measured.

The two decisions worth reading

The lookup sits after the --component rewrite. The same question put to a Chimera concatenates
worm and bacterial ids across its sources, and a share pooling two organisms is a share of a
population nobody asked about. The counter already runs once per Component, so asking the
Component's own genome about its own annotation makes that impossible and needs no handling of
sources here at all. Pinned by a test that goes red if the call moves above the rewrite.

An annotation that cannot answer yields no column, never a zero. A plate whose every cell reads
0.0% rRNA is indistinguishable from a plate that really is ribosome-free, and it is the one answer
nobody may act on. No curated list for this annotation, no rRNA category in the one that ships, or
no named id on the gene axis — all three write neither column, and the reader omits the metric the
way it does for a column an older object never carried. A curated file that is present and broken
is a different thing and stays a refusal.

What is deliberately not here

No uns caveat, no var flag, no gene ids on the object, no threshold and no grade, and no change
to any matrix. rRNA is still counted as expression, because those reads map uniquely and excluding a
uniquely-mapped population on a category basis is a counting decision that would need its own
argument. #427 argued against grading a number nobody has measured a bar for, and this does not
smuggle one back in. The gene set is retrievable from liulab-genome, which is its single source of
truth, so the object does not carry a second copy.

Stamps

WORKFLOW_VERSION 2026.8.23 -> 2026.8.24. The bump is owed by the ARTIFACT and not by a moved
command line, the same as 2026.8.18: the rule's params, its declared outputs and every matrix it
writes are byte-identical, and what moves is the shape of the object that comes out. REPORT_VERSION
stays put — the renderer is generic over metrics, and that constant tracks the page's structure.

pixi.lock moves liulab-genome to the commit that added gene_list.

Closes #471
Closes #472

🤖 Generated with Claude Code

lhqing and others added 4 commits August 22, 2026 12:58
The lock held liulab-genome at ce23e02, which predates `Genome.gene_list` —
the accessor that hands back the gene ids under a biotype. The pin was fine
for as long as nothing here asked an annotation which of its genes were
which; the per-cell rRNA metric that follows is the first caller that does,
and against the old pin it cannot import.

Nothing else in the lock moves: the only lines that differ are the six
per-platform pins of that one git dependency and the version string derived
from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A ribosomal fragment maps uniquely, reaches exactly one gene, and is credited
to it like any other — nothing in this counter filters one out and nothing
downstream of it does either. So a plate object can report an ordinary-looking
molecule total over an ordinary-looking number of genes while almost none of it
is the transcriptome anybody asked for, and until now there was nothing on the
object that would say so. Two `obs` columns say it: `n_umis_rrna`, the cell's
deduplicated molecules falling in ribosomal genes, and `rrna_fraction`, that
count over `n_umis`.

Both are counted over `umi_combined` and never over `X`. That is the same
population `n_umis`, `genes_detected` and the saturation are all read off, so
the share and the total it is a share OF are one number rather than two
derivations nothing forces to agree; the primary matrix is exonic, and a
numerator taken off it would report a different share of a different
population. A cell with no molecules at all gets `nan` and never `0.0` — the
denominator is the arithmetic with no answer, exactly as it is for saturation,
and the reader already reads that back as absence.

The ids arrive as a parameter rather than being looked up here. This module
takes a resolved annotation database and never an assembly id, precisely so the
`liulab-genome` import can live in the CLI verb and this stay strictly typed and
testable against a synthetic annotation; the ribosomal gene set is the same kind
of fact and enters the same way. An id that is not on the gene axis is dropped
and the rest are counted, because the ribosomal set is curated per assembly
while the axis is whatever GTF was registered, and the two overlap partially all
the time.

What an EMPTY overlap does is the decision this whole measurement rests on:
neither column is written at all. A plate whose every cell reads `0.0% rRNA` is
indistinguishable from a plate that really is ribosome-free, and it is the one
answer nobody may act on — so where no gene was named, or none of the named ones
is on this annotation, the object carries no ribosomal column and the report
omits the metric, which is what an unmeasured thing looks like everywhere else
on this path. It is absence rather than a refusal because the plate itself
counted fine: what is missing is a QC number about it, and refusing a whole
plate over a gene list would cost every cell's matrices for a column.

The page gets the share and not the count. A raw ribosomal molecule total is not
comparable across cells whose depths differ by three orders of magnitude, which
is the trade the four fates already make against `n_fragments`; the count stays
on the object for whoever wants to recompute. It is ungraded, like every other
column this reader builds — how much rRNA a library should carry is a property
of the prep and the organism, nobody has measured a bar for it, and a bar
invented at review is worse than a number a reader compares across the plate
themselves. Nothing else moves: no matrix, no `uns` note, no `var` column, and
no gene the counter used to keep is dropped now.

`WORKFLOW_VERSION` moves to 2026.8.24 because the ARTIFACT changed and not
because a command line did. The rule's params, its declared outputs and every
matrix it writes are byte-identical; what moves is the shape of the object that
comes out, and an h5ad written before this beside one written after would
otherwise both claim the same workflow produced them.

Closes #471

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The counter has taken a ribosomal gene set since the commit before this one, and
nothing handed it one, so no plate ever got the two `obs` columns it writes from
them. `io umi-count` supplies them now: the annotation it has just resolved is
asked for its rRNA gene list, and the ids go straight into `write_umi_counts`.
The verb is where that question belongs for the same reason the annotation
database is resolved here — the package that knows which genes are which is the
one this file already imports, and the counting module stays strictly typed and
testable against a synthetic annotation with no genome store in sight.

The lookup sits AFTER the `--component` arm has rewritten the pair, which is
load-bearing rather than incidental. Asked of a Chimera, `gene_list("rRNA")`
concatenates the worm's ids and the bacterium's across its sources, and a
fraction whose numerator pools two organisms is a share of a population nobody
asked about. The counter already runs once per Component, so asking the
component's own genome about the component's own registered annotation makes
that impossible by construction — and seqforge handles no `.sources` at all.

The two ways an annotation can decline the question are caught BY NAME:
`NoGeneCategoriesError`, where no curated list ships for it, and
`GeneCategoryNotDeclaredError`, where one ships and does not name ribosomes.
Either costs the plate that one column and never a zero one, silently and with
nothing on stdout, which is what an unmeasured thing looks like everywhere else
on this path. Deliberately NOT `LookupError`: an unregistered annotation raises
an `AnnotationNotRegisteredError`, which is a `KeyError` and therefore also a
`LookupError`, and catching the base class would turn that refusal into a
silently missing column. A curated file that is present and broken, or curated
against another assembly, raises a `ValueError` and falls through to the exit
that was already there — a defect in a shipped data file must not become an
absent metric either.

Both existing `io umi-count` tests stub `Genome`, so both stubs grow a
`gene_list`; the first is now parametrized over what the annotation answers,
with the two absences as rows, and reads the columns back off the written object
rather than off the seam — a plate whose annotation could not answer carries
neither. The `--component` test pins who was asked, since a lookup moved one
line earlier would pool two organisms and produce a number that looks fine.

Closes #472

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mal columns

Review follow-up on the three commits that added the ribosomal share. All
tidy-up: no behaviour moves, no artifact changes shape, and the composed
pipeline renders the same bytes.

The one real breach was `count.py`'s module docstring. It enumerates the `obs`
surface in prose a few lines after warning that a surface spelled again in prose
is the copy that goes stale — and two columns had landed since anyone reread it.
The sentence now carries the ribosomal share as a clause, conditional on the
annotation being able to name those genes, and defers the argument to the
constant that holds it.

`_GENOME_API` in the repo invariants gains `gene_list`. It is the hand-written
set of every liulab-genome attribute we call, asserted against the real package
so an upstream rename goes red; the verb had grown a call site the set did not
know about, which is exactly the drift it exists to catch.

`io umi-count` built `Genome(assembly)` twice in one `try` — once for the
annotation path and once for the gene list. Bound once, after the `--component`
rewrite has settled the pair, which is the ordering both lookups depend on and a
test pins.

`N_UMIS_RRNA` leaves the `_obs_columns` whitelist. `fate_metrics` never reads
that key — the page carries the share and not the count beside it, which was
settled — so listing it read a column back for nobody. The count stays on the
written object, and the metric table's claim that it earns no report row is
unchanged.

Prose: the Chimera-concatenates-every-Component's-ids argument was made in full
twice in `io.py`, and "neither column rather than a zero" three times over the
one at `RRNA_FRACTION`. Each keeps one home and the rest point at it.

Tests: `test_cli.py` names the two columns by the constants the counter exports
rather than by string literal, and its happy-path row drops the arithmetic. What
those columns hold is proved against a synthetic annotation in the counter's own
tests, so the CLI row was a second test going red for one cause; what it claims
on its own — that the ids the genome answered with reach `write_umi_counts` and
that the columns land on the object — it still asserts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 22, 2026 17:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@lhqing
lhqing merged commit 32cd362 into main Aug 22, 2026
6 checks passed
@lhqing
lhqing deleted the feat/471-rrna-fraction branch August 22, 2026 17:28
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.

Ribosomal RNA is counted as expression, and nothing on the object says so A cell can say what fraction of it was ribosomal RNA

2 participants