a cell says how much of its yield was ribosomal - #491
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
obscolumns on the plate object, written byrule umi_count:n_umis_rrna— deduplicated molecules in ribosomal genes.rrna_fraction— that count overn_umis,nanfor a cell with no molecules.Both counted over
umi_combined, the same populationn_umis,genes_detectedand the saturationare 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 curatedrRNAcategory for the annotation the plate was actuallycounted 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 nolist 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
--componentrewrite. The same question put to a Chimera concatenatesworm 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% rRNAis indistinguishable from a plate that really is ribosome-free, and it is the one answernobody may act on. No curated list for this annotation, no
rRNAcategory in the one that ships, orno 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
unscaveat, novarflag, no gene ids on the object, no threshold and no grade, and no changeto 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 oftruth, so the object does not carry a second copy.
Stamps
WORKFLOW_VERSION2026.8.23 -> 2026.8.24. The bump is owed by the ARTIFACT and not by a movedcommand 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_VERSIONstays put — the renderer is generic over metrics, and that constant tracks the page's structure.
pixi.lockmovesliulab-genometo the commit that addedgene_list.Closes #471
Closes #472
🤖 Generated with Claude Code