Skip to content

fix(solo): MultiGeneUMI_CR was inert — a tied UMI goes to nobody, not everybody - #173

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

fix(solo): MultiGeneUMI_CR was inert — a tied UMI goes to nobody, not everybody#173
BenjaminDEMAILLE wants to merge 2 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/solo-multigene-cr

Conversation

@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor

--soloUMIfiltering MultiGeneUMI_CR removed nothing at all. It kept every gene tied at the highest read count; CellRanger's rule gives a tied UMI to no gene.

Fixes the first of the two inert flags in #172.

The rule

STAR walks the genes for a UMI keeping a running maximum, and clears its winner whenever it meets an equal count (SoloFeature_collapseUMIall.cpp:212-224):

uint32 maxu=0, maxg=-1;
for (const auto &ig : iu.second) {
    if (ig.second>maxu) { maxu=ig.second; maxg=ig.first; }
    else if (ig.second==maxu) { maxg=-1; };
};
if ( maxg+1==0 )
    continue; //this umi is not counted for any gene, because two genes have the same read count for this UMI

Strict maximum takes the UMI; a tie takes it from everyone. The code here kept rc >= max, which on a tie keeps all the tied genes — the opposite decision on precisely the case the rule exists to settle.

That is why the flag was inert rather than merely inaccurate: one read per gene is the ordinary shape of a multi-gene UMI, and it is always a tie.

Measured

20 000-read 10x fixture: 200 cells drawn from the real 3M-february-2018 whitelist, 400 yeast genes, 720 UMIs deliberately shared between two genes in the same cell. STAR 2.7.11b against this branch, same flags, raw Gene matrix compared entry by entry.

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 gap goes from +1 042 counts to −9. STAR removes 1 030 counts with this flag; before this change rustar removed zero.

With the full CellRanger-matching flag set, the total gap drops from +1 341 (+8.9%) to +290 (+1.9%). The remainder is --soloCBmatchWLtype 1MM_multi_Nbase_pseudocounts, the second inert flag in #172, which is a separate change.

Determinism

The rule is order-independent: a strict maximum always ends as the winner whatever order the genes are visited in, and a tie at the maximum always ends with none. Iterating a HashMap here therefore stays deterministic, and the comment says so at the site.

What is still missing, said plainly

STAR applies a second condition I have not implemented: the winning gene must also hold the top count among uncorrected UMIs (umiGeneMapCount0, same file, lines 226-232). That needs the counts from before UMI correction, which this code does not retain, so it is a larger change. The 65 entries still differing out of 13 967 are where to look for its effect — 30 STAR-only, 23 rustar-only, 12 differing.

I would rather land the 1 030-count fix now and treat the second condition as its own change than bundle a restructure into it.

Verification

  • multi_gene_umi_cr_drops_a_tie_entirely — a two-gene tie, a tie at the maximum with a third gene below it, and a strict maximum that still wins. The existing multi_gene_umi_cr_keeps_top_gene only covered 3 reads against 1, where both rules agree, which is why the defect survived.

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

The fixture generator and matrix comparison are not in this PR; they belong with the replacement of the three-entry differential (item 4 of #172), so that the numbers above become a test rather than a claim.

BenjaminDEMAILLE and others added 2 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>
@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor Author

The two inert flags in #172 turn out to be fixed by two separate PRs, and together they close almost the whole gap.

Attribution

Same 20 000-read 10x fixture, STAR 2.7.11b against each build, raw Gene matrix compared entry by entry.

--soloCBmatchWLtype 1MM_multi_Nbase_pseudocounts alone:

build identical entries STAR counts rustar counts
main 14 437 / 14 806 16 120 16 465
#165 14 518 / 14 530 16 120 16 108

The pseudocounts were already wired on main; what was missing is the cbMinP posterior threshold, so nothing ever rejected a low-confidence correction and the flag could not change an outcome. #165 adds the threshold and the oneExact guard, and the flag starts working.

#165 also fixes the baseline: at default flags it takes rustar from 21 entries STAR does not have down to zero, 14 773 / 14 787 identical.

--soloUMIfiltering MultiGeneUMI_CR alone is #173 (tie goes to nobody): +1 042 counts to −9.

Both together

build identical entries STAR counts rustar counts gap
main 13 458 / 14 804 (90.9%) 15 124 16 465 +1 341 (+8.9%)
#165 only 13 532 / 14 528 15 124 16 108 +984
#173 only 13 585 / 13 979 15 124 15 414 +290
#165 + #173 13 671 / 13 708 (99.73%) 15 124 15 091 −33 (−0.22%)

Full CellRanger-matching flag set, so this is the configuration the flags exist for.

What is left

37 entries of 13 708: 28 STAR-only, 2 rustar-only, 7 present in both and differing.

One known cause, already stated on #173: STAR requires the winning gene of a multi-gene UMI to hold the top count among uncorrected UMIs as well (SoloFeature_collapseUMIall.cpp:226-232), which needs the pre-correction counts that the current code does not retain. That is the next thing to chase, and it is a restructure rather than a one-line rule change.

The 28 STAR-only entries are the shape that condition would produce — STAR counting a UMI that we drop — so I would look there first.

Note on merge order

Neither PR depends on the other, but they touch src/solo/count.rs and will conflict trivially on CHANGELOG.md. Whichever lands second needs a rebase, and the combined figure above only holds with both.

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