Skip to content

feat(solo): --soloOutRawBarcodes Observed — a CellRanger-shaped raw matrix (stacked on #173) - #174

Open
BenjaminDEMAILLE wants to merge 3 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/solo-raw-observed-barcodes
Open

feat(solo): --soloOutRawBarcodes Observed — a CellRanger-shaped raw matrix (stacked on #173)#174
BenjaminDEMAILLE wants to merge 3 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/solo-raw-observed-barcodes

Conversation

@BenjaminDEMAILLE

@BenjaminDEMAILLE BenjaminDEMAILLE commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Stacked on #173 — contains its commits. Merge #173 first; this rebases to a single commit afterwards.

Comparing our raw matrix against a real cellranger count run 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 mkref on the yeast reference, then cellranger 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. Their barcodes.tsv is 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 -1 GEM-well suffix to every barcode. That is a separate change and I would rather not bundle it.

What this adds

--soloOutRawBarcodes Observed narrows the raw matrix to barcodes carrying a count. Default Whitelist is what STARsolo writes, so nothing changes for anyone not asking.

barcodes barcodes.tsv
Whitelist (default) 3 686 400 62 668 800 bytes
Observed 200 3 400 bytes

Counts are identical either way: 13 937 entries, 15 414 counts on both sides, checked entry by entry. Only the columns present differ.

finalize_matrix already 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

--soloOutRawBarcodes does not exist in STAR. CONTRIBUTING.md requires that to be stated rather than presented as parity, so it is in DIVERGENCE.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 -1 suffix, 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

  • counts identical between the two modes, entry by entry, on the 20 000-read fixture
  • gate: 561 lib + 26 integration tests, cargo clippy --all-targets -- -D warnings, cargo fmt --check, all green

Not 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.

BenjaminDEMAILLE and others added 3 commits July 31, 2026 10:06
`--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>
@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor Author

Correction: the CellRanger numbers in this PR do not reproduce

I 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 cellranger count run, one command per row:

build entries counts vs CellRanger
CellRanger 10.0.0 13 690 15 111
STAR 2.7.11b, five flags 13 706 15 124 +13 (+0.09%)
rustar main, no flags 14 798 16 465 +1 354 (+8.96%)
rustar main, five flags 14 798 16 465 +1 354 (+8.96%)
rustar #173 13 937 15 414 +303 (+2.01%)
rustar #175 13 959 15 439 +328 (+2.17%)
rustar #178, no flags at all 13 959 15 439 +328 (+2.17%)

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 happened

The 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 PR

Nothing 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.

@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor Author

Retracting the correction above

The 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 cellranger count run:

build entries counts vs CellRanger
CellRanger 10.0.0 13 690 15 111
STAR 2.7.11b, five flags 13 706 15 124 +13 (+0.09%)
rustar main, no flags 14 798 16 465 +1 354 (+8.96%)
this stack without #165 13 959 15 439 +328 (+2.17%)
this stack with #165 13 702 15 116 +5 (+0.03%)

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.

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