feat(solo): --soloOutRawBarcodes Observed — a CellRanger-shaped raw matrix (stacked on #173) - #174
Conversation
`--soloUMIfiltering MultiGeneUMI_CR` kept every gene tied at the highest
read count. CellRanger's rule is the opposite on exactly that case: the
gene with the *strictly* highest count takes the UMI, and a tie means no
gene counts it.
STAR walks the genes keeping a running maximum and clears its winner
whenever it meets an equal count
(`SoloFeature_collapseUMIall.cpp:212-224`):
if (ig.second>maxu) { maxu=ig.second; maxg=ig.first; }
else if (ig.second==maxu) { maxg=-1; };
...
if ( maxg+1==0 ) continue; // not counted for any gene
One read per gene is the ordinary shape of a multi-gene UMI, and it is
always a tie, so the old rule made the flag inert in practice rather
than merely inaccurate. Measured on a 20 000-read 10x fixture (200 cells
from the real v3 whitelist, 400 genes, 720 UMIs deliberately shared
between two genes), against STAR 2.7.11b with the same flags:
identical entries STAR counts rustar counts
before 13 749 / 14 806 15 423 16 465
after 13 902 / 13 967 15 423 15 414
The flag removed nothing at all before; STAR removes 1 030 counts. The
gap goes from +1 042 to -9.
The outcome does not depend on the order the genes are visited — a
strict maximum always ends as the winner, a tie always ends with none —
so iterating a `HashMap` here stays deterministic.
`multi_gene_umi_cr_drops_a_tie_entirely` pins the case the old tests
missed: they only covered 3 reads against 1, where both rules agree.
Not yet implemented, and stated so rather than left to be discovered:
STAR applies a second condition, that the winning gene must also hold
the top count among *uncorrected* UMIs (`umiGeneMapCount0`, same file,
lines 226-232). That needs the pre-correction counts, which this code
does not keep. The 65 entries still differing out of 13 967 are the
place to look for its effect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w matrix
STARsolo's raw matrix has a column per whitelist barcode. For 10x v3 that
is 3 686 400 columns and a 62 MB `barcodes.tsv`, nearly all zeros.
CellRanger's `raw_feature_bc_matrix` has a column per *observed* barcode.
The two files therefore share no keys, which is not a rounding difference
in a comparison, it is zero overlap: comparing our raw output against a
real `cellranger count` run gave 0 identical entries out of 27 396 until
the columns were reconciled.
`--soloOutRawBarcodes Observed` narrows the raw matrix to the barcodes
that carry a count. Default `Whitelist` keeps what STARsolo writes, so
nothing changes for anyone not asking.
Measured on the 20 000-read fixture:
Whitelist 3 686 400 barcodes barcodes.tsv 62 668 800 bytes
Observed 200 barcodes barcodes.tsv 3 400 bytes
with identical counts on both sides: 13 937 entries, 15 414 counts.
`finalize_matrix` already took a column remap for the filtered matrix, so
this reuses it rather than adding a second path. The observed set is read
back from the streamed body, which costs one pass and only when the flag
is on.
This is a **non-STAR flag** and needs sign-off; recorded in
`DIVERGENCE.md` §3.2 rather than presented as parity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correction: the CellRanger numbers in this PR do not reproduceI re-derived the whole comparison from a clean state today, both sides in one pass, and the absolute figures I published are wrong. The relative effects hold. The headline claim does not, and it was the headline, so this needs saying plainly rather than in a footnote. Everything below is from today, one fixture, one
What survives
What does not survive"within 0.03%", "15 116", and "rustar is closer to CellRanger than STAR is". All three are wrong. rustar is at +2.17%; STAR is at +0.09%. Compared directly against STAR under identical flags, rustar is +315 counts with 273 entries STAR does not have. That is a real open divergence, and I reported it as closed. How it happenedThe fixture reference was regenerated between that measurement and this one, and I compared a rustar run against a CellRanger run derived from a different state of it. The relative deltas were unaffected, which is why the per-PR effects still reproduce and I did not notice. I should have re-derived both sides in one pass before publishing an absolute figure, and from here I will. What this changes about the PRNothing about the code, and nothing about the argument for it: the flags still move the matrix from 8.96% to 2.17% off CellRanger, which is most of the gap and is what the change is for. What changes is that the remaining 2.17% is open, not closed, and the 273 entries rustar has that STAR does not are the next thing to chase. I would rather that be visible in the review than discovered later. I have edited the numbers in the PR description to match this table. |
Retracting the correction aboveThe correction I posted earlier was itself wrong, and I would rather say so immediately than leave it standing. The original numbers in this PR were right. What I failed to hold constant when I re-measured was #165, which is an open PR and not in this stack's base. Re-measured today, both sides in one pass, one fixture, one
The last row reproduces the original claim exactly, including the 13 676 / 13 709 identical entries. The PR body already named the precondition — it said "with #165 and #173" — and I dropped that condition when re-measuring, then published the resulting worse number as a correction of a claim that was never wrong. So, to be unambiguous about what holds:
Practical consequence for review: #165 should merge before this stack, or the numbers in these PRs will not reproduce. I have restored the original figures in the description and made that dependency explicit rather than parenthetical. The lesson I am taking from it, since it cost you reading time twice: when a measurement disagrees with a published one, the first thing to check is what moved between them, not the published number. |
Stacked on #173 — contains its commits. Merge #173 first; this rebases to a single commit afterwards.
Comparing our raw matrix against a real
cellranger countrun gave 0 identical entries out of 27 396. Not a rounding difference: zero overlap, because "raw" means two different things.The finding
I ran CellRanger 10.0.0 on the fixture from #172 (
cellranger mkrefon the yeast reference, thencellranger count), and its raw matrix has 200 barcode columns — the ones observed. STARsolo's has 3 686 400 — the whole v3 whitelist, nearly all zeros. Theirbarcodes.tsvis a few kilobytes; ours is 62 MB.Two normalisations were needed before the matrices could be compared at all, and the other one is not in this PR: CellRanger appends a
-1GEM-well suffix to every barcode. That is a separate change and I would rather not bundle it.What this adds
--soloOutRawBarcodes Observednarrows the raw matrix to barcodes carrying a count. DefaultWhitelistis what STARsolo writes, so nothing changes for anyone not asking.barcodes.tsvWhitelist(default)ObservedCounts are identical either way: 13 937 entries, 15 414 counts on both sides, checked entry by entry. Only the columns present differ.
finalize_matrixalready accepted a column remap for the filtered matrix, so this reuses that path instead of adding a second one. The observed set is read back from the streamed body — one extra pass, and only when the flag is on.Non-STAR flag, needs sign-off
--soloOutRawBarcodesdoes not exist in STAR.CONTRIBUTING.mdrequires that to be stated rather than presented as parity, so it is inDIVERGENCE.md§3.2 with the rationale, and it is off by default.I am not attached to the name. If you would rather have one umbrella flag for CellRanger-shaped output — this, the
-1suffix, the directory names — say so and I will fold them together instead.Where this sits
With #165 applied, and with #173, the count matrix is within 0.13% of real CellRanger on this fixture (15 091 against 15 111; STAR itself is at 15 124, +0.09% the other way). #165 is a precondition, not a footnote: without it the same stack sits at 15 439, +2.17%. It should merge first. Numbers and method in #172, and the full table is in the comment thread below.
This PR changes no counts at all. It changes which columns are written, which is what stops the two outputs being comparable in the first place.
Verification
cargo clippy --all-targets -- -D warnings,cargo fmt --check, all greenNot covered by a unit test yet: the flag is exercised end to end above but the fixture that proves it lives outside the repo, alongside the replacement for the three-entry differential (item 4 of #172). I would rather land that fixture once and have it cover this, #173 and the CB-match work together than write a weaker synthetic test three times.