Skip to content

fix(solo): MultiGeneUMI_CR decides ownership on corrected UMIs (stacked on #174) - #175

Open
BenjaminDEMAILLE wants to merge 4 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/solo-multigene-cr-corrected
Open

fix(solo): MultiGeneUMI_CR decides ownership on corrected UMIs (stacked on #174)#175
BenjaminDEMAILLE wants to merge 4 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/solo-multigene-cr-corrected

Conversation

@BenjaminDEMAILLE

@BenjaminDEMAILLE BenjaminDEMAILLE commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Stacked on #174, which is stacked on #173. Merge in that order; this rebases to one commit afterwards.

Finishes the MultiGeneUMI_CR rule. #173 fixed the tie; this adds the condition I explicitly deferred there, and it needed the restructure I said it would.

What STAR actually does

Two things, in this order (SoloFeature_collapseUMIall.cpp:134-148, then :203-235):

UMIs are corrected within each gene first, and the per-gene read totals are recorded twice — once under the raw UMI (umiGeneMapCount0), once under the corrected one (umiGeneMapCount).

Then ownership is decided on the corrected map, subject to two conditions:

  1. one gene holds a strictly higher read count than every other — a tie counts for nobody. That is fix(solo): MultiGeneUMI_CR was inert — a tied UMI goes to nobody, not everybody #173.
  2. and that winner is not beaten in the uncorrected map at the same key:
for (const auto &ig : umiGeneMapCount0[iu.first]) {
    if (ig.second>umiGeneMapCount0[iu.first][maxg]) { maxg=-1; break; };
};

The second condition exists because correction moves reads between UMIs. A gene can end up winning only because correction folded a neighbouring UMI onto it, and STAR refuses that win instead of counting it.

Why this could not be a smaller change

The generic path here filters multi-gene UMIs first and corrects afterwards. That order cannot express either condition: by the time correction runs, ownership is already decided. So MultiGeneUMI_CR now takes its own path through the per-cell loop, which mirrors STAR — the flag is only valid with --soloUMIdedup 1MM_CR, so no other combination is affected.

cellranger_1mm_map exposes the mapping cellranger_1mm was already computing and discarding.

Measured against CellRanger 10.0.0

Real cellranger count, not a proxy: cellranger mkref on the yeast reference, then cellranger count on the fixture from #172. With #165 and #173 also applied:

identical entries CellRanger rustar gap
#165 + #173 13 651 / 13 709 15 111 15 091 −20
plus this 13 676 / 13 709 (99.76%) 15 111 15 116 +5 (+0.03%)

Entries CellRanger has and we do not: 29 → 7.

#165 is a precondition for these numbers and should merge first; without it the same stack sits at 15 439, +2.17%. With it, STAR 2.7.11b on the same fixture with the same flags counts 15 124, so it sits +13 from CellRanger while this sits +5. All three agree to within a fraction of a percent. The full table is in the comment thread below.

Verification

Gate: 562 lib + 26 integration tests, cargo clippy --all-targets -- -D warnings, cargo fmt --check, all green.

What is still different

7 entries CellRanger has that we do not, 19 the other way, of 13 709. I have not chased them and will not guess at a cause here.

The honest limit of this method: anything left is small enough that telling a genuine algorithmic difference from a fixture artefact needs a second, differently-shaped dataset. That belongs with the fixture work in #172 rather than with this rule.

BenjaminDEMAILLE and others added 4 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>
STAR corrects UMIs within each gene *before* deciding which gene owns a
UMI, and applies two conditions, not one
(`SoloFeature_collapseUMIall.cpp:134-148` and `:203-235`):

1. one gene must hold a strictly higher read count than every other, on
   the **corrected** UMI map — that is scverse#173, already landed;
2. and that winner must not be beaten in the **uncorrected** map at the
   same key.

The second condition exists because correction moves reads between UMIs:
a gene can win only because correction folded a neighbouring UMI onto it,
and STAR rejects that win rather than counting it.

Reproducing it needs the order STAR uses. The generic path here filters
multi-gene UMIs first and corrects afterwards, which cannot express either
condition: by the time correction happens the ownership decision is
already made. `MultiGeneUMI_CR` therefore takes its own path, which is
also what STAR does — the flag is only valid with `--soloUMIdedup 1MM_CR`,
so there is no combination this bypasses.

`cellranger_1mm_map` exposes the correction mapping that
`cellranger_1mm` already computed and threw away.

Measured against **CellRanger 10.0.0** on the 20 000-read fixture from
scverse#172, with scverse#165 and scverse#173 also applied:

                              identical entries   CellRanger   rustar
    scverse#165 + scverse#173               13 651 / 13 709        15 111     15 091
    plus this change          13 676 / 13 709        15 111     15 116

Entries CellRanger has and we do not go from 29 to 7, and the count gap
from -20 to +5, which is 0.03%.

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