solo: cbMinP posterior threshold, oneExact guard, adapter-anchored geometry (replaces #150) - #165
Conversation
…uard `resolve_multi_cb` computed the count+quality posterior correctly, computed the normalising total, and then ignored it: any candidate with positive weight won by argmax. Two of STAR's conditions were missing. `cbMinP`. STAR requires the winner to hold at least 97.5% of the total posterior mass. Without it a barcode is assigned whenever any candidate has positive weight, including when two candidates are nearly tied — which is precisely the case the threshold exists to reject. A read whose barcode cannot be singled out should be discarded, not guessed. `oneExact`. Unless pseudocounts are in use, the winner must have been observed as an exact match at least once. A candidate whose only support is a pseudocount is eligible, not evidence. **This changes default counting behaviour**, and the existing unit test had to change with it: it asserted that priors of 10 against 3 resolve to the higher one, which is a 77% posterior share and now correctly resolves to nothing. The test was encoding the missing threshold. It now covers both sides — a decisive 1000-against-3 prior is accepted, a tie under pseudocounts is not. Verification I could not do here, and would want before this merges: the solo differential against real STARsolo (`test/solo_diff_docker.sh`). Reads that previously landed on an ambiguous barcode now land nowhere, so raw matrix counts will move, and the direction should be confirmed against the oracle rather than argued from the source. Everything in-tree is green — 560 lib + 26 integration — but that is not the same thing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er-UMI rule `--soloOutFormatFeaturesGeneField3` sets the third column of `features.tsv`, STAR's default being `Gene Expression`. The sentinel `-` drops the column, giving the two-column form some downstream tools expect. Previously the string was hard-coded. Also adds `umi_valid_rejects_every_homopolymer`. No code change was needed — `is_homopolymer` already rejects a UMI of any single repeated base — but the rule deserves a test that says why it is deliberate. STAR has a quirk (`umiL = 0`) under which a non-A homopolymer UMI slips through its filter, so poly-C, poly-G and poly-T survive there and are counted. That is a defect rather than a rule: a UMI of one repeated base carries no information whichever base it happens to be. This diverges from 2.7.11b knowingly, and the test now records that instead of leaving it to be rediscovered as a difference in a differential run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… String `--soloChemistry` sets the CB/UMI geometry from a 10x chemistry name so it does not have to be spelled out four flags at a time: SC3Pv1 (14-base CB, 10-base UMI), SC3Pv2 (16/10), SC3Pv3, SC3Pv4 and SC5P (16/12), plus the CellRanger aliases. Unknown names are refused. The preset wins over an explicit `--soloCBstart` and friends, and warns when it overrode one. Half-applying a preset — taking its CB length but the flag's UMI start, say — would produce a geometry neither the user nor the preset asked for, and would fail silently in the counts rather than loudly at startup. `--soloCBtype` is declared and accepts `Sequence`. `String` is refused rather than accepted and ignored: barcodes here are 2-bit packed ACGT, and a non-ACGT barcode packed that way is nonsense rather than an approximation. Supporting it means a different whitelist representation throughout, which is its own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--soloCBposition` and `--soloUMIposition` take four fields, `startAnchor_startDist_endAnchor_endDist`, where the anchor codes are 0 = read start, 1 = read end, 2 = adapter start, 3 = adapter end. Only code 0 was accepted; the rest were a parse error. Codes 2 and 3 are the reason this needs a new layout rather than a wider parser: the adapter moves from read to read, so the offsets cannot be resolved once at startup the way every other layout's are. `SoloBarcodeLayout::Anchored` carries the specs with their anchors and the adapter, and resolves per read. Adds `--soloAdapterSequence` and `--soloAdapterMismatchesNmax`. The adapter search takes the leftmost position within the mismatch budget, which is what STAR takes — the first acceptable match, not the best-scoring one. A read where the adapter is not found, or where an anchor resolves outside the read, yields no barcode. Guessing would be worse than declining: a barcode read at an unknown offset is not salvageable, and a wrong barcode is a read attributed to the wrong cell rather than a read lost. Tests cover the adapter moving (both fields follow it), one mismatch inside the budget, no adapter at all, an anchor running off the front of the read, and the leftmost-match rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eftmost STAR's `localAlignHammingDist` (`SequenceFuns.cpp:341`) scores every offset and keeps the one with the fewest mismatches. This took the first offset that fit the mismatch budget, which is the opposite rule: an early approximate match beat a later exact one. Since `CB_UMI_Complex` measures its barcode positions from the adapter, choosing the wrong offset shifts the whole barcode, so this decides what the read is counted as, not merely where a match is reported. Reported by @Psy-Fer on scverse#150, with the upstream source. The tests did not catch it because their fixtures had a single candidate or an exact one, where leftmost and best-scoring give the same answer. There is now a test where they differ. Two further details from the same function, both of which were missing: Ties go to the leftmost of the best, because STAR replaces its incumbent only on a strictly smaller distance. An `N` in the adapter never counts as a mismatch. It is a wildcard in the query, not a base that fails to match, and treating it as a mismatch spends budget that STAR does not spend. STAR also seeds its best distance with the adapter length, so an offset that mismatches everywhere can never win. That only shows when the budget is as large as the adapter, where it keeps the answer `None` instead of an arbitrary offset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--soloChemistry` does not exist in STAR 2.7.11b; it was carried over from STAR-rs. Adding it here widens the CLI beyond STAR's, which is a positioning decision for this project rather than something to arrive inside a PR about the barcode posterior. Raised by @Psy-Fer on scverse#150. The geometry it set is still reachable through the STAR flags it was a shorthand for: --soloCBstart, --soloCBlen, --soloUMIstart, --soloUMIlen. `--soloCBtype String` stays refused, as before: it is a real STAR parameter and refusing an unimplemented value is the honest answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rustar-aligner has always rejected poly-A/C/G/T UMIs in every geometry. STAR rejects only poly-A under CB_UMI_Complex, because the four packed constants are built in the SoloReadBarcode constructor from pSolo.umiL while that path does not learn its UMI length until it has extracted one (SoloReadBarcode.cpp:16-21 against getCBandUMI.cpp:353-354), leaving all four constants zero. Poly-A packs to zero and is still caught; the other three are not. CONTRIBUTING requires a deliberate divergence to be recorded with the STAR source it departs from, and this one was not. Verified against the 2.7.11b source rather than asserted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… tests Both rules were asserted inside resolve_multi_prefers_higher_prior, whose name describes only the ordering. The threshold decides between correcting a barcode and dropping the read, and oneExact is not implied by it (a sole candidate holds the whole posterior and is still refused), so each gets a test that says what it checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
33c5e7d to
e77ac0a
Compare
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The two inert flags in #172 turn out to be fixed by two separate PRs, and together they close almost the whole gap. AttributionSame 20 000-read 10x fixture, STAR 2.7.11b against each build, raw
The pseudocounts were already wired on #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.
Both together
Full CellRanger-matching flag set, so this is the configuration the flags exist for. What is left37 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 ( 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 orderNeither PR depends on the other, but they touch |
The figure I replaced this with was measured without scverse#165, whose cbMinP posterior threshold is a precondition for it. With scverse#165 the five flags move the matrix from 8.96% above CellRanger to 0.03% above it, which is what the original text said. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
STARsolo barcode resolution: the
cbMinPposterior threshold, theoneExactguard,--soloCBtype, adapter-anchoredCB_UMI_Complexgeometry, and--soloOutFormatFeaturesGeneField3.Replaces #150, rebased onto current
main. Opening a new PR rather than force-pushing that branch, so the review history there stays readable. Every objection @Psy-Fer raised on #150 is addressed below, including one that was a real correctness bug.What #150 got wrong, and what changed
The adapter search took the leftmost acceptable match; STAR takes the best-scoring one. @Psy-Fer's diagnosis was exact, including why the tests missed it.
localAlignHammingDist(SequenceFuns.cpp:341) scores every offset and keeps the fewest-mismatch one:The old code returned the first offset inside the mismatch budget, which is the opposite rule: an early approximate match beat a later exact one. This is not cosmetic.
CB_UMI_Complexmeasures its barcode positions from the adapter, so the wrong offset shifts the whole barcode and changes what the read is counted as.Reading that function properly surfaced two more details the old code also had wrong:
Nin the adapter is never a mismatch. It is a wildcard in the query, not a base that fails to match, and charging it spends budget STAR does not spend.STAR also seeds
distBestwith the query length, so an offset mismatching everywhere can never win. That only shows when the budget is as large as the adapter, where it keeps the answerNoneinstead of an arbitrary offset. Reproduced.find_adapter_takes_the_best_scoring_match_not_the_leftmostis the discriminating case the old tests lacked: a one-mismatch match at offset 0 against an exact one at offset 5. Leftmost gives 0, STAR gives 5. Plusfind_adapter_breaks_ties_leftmostandan_n_in_the_adapter_is_not_a_mismatch.--soloChemistryand--soloCBwhitelistDirare gone. Both objections were right and they were the same mistake: neither flag exists in STAR. They came from the STAR-rs donor, which invented them, and #150 presented them as parity work without saying so. PerCONTRIBUTING.mda non-STAR flag needs explicit sign-off and must never be presented as faithfulness, so rather than relabel them I reverted them. If chemistry presets are wanted they should be their own proposal, argued as an addition.DIVERGENCE.mdhad no entry, and needed one. Added §1.2, with the STAR source checked rather than recalled: rustar rejects all four homopolymer UMIs, STAR underCB_UMI_Complexrejects only poly-A. The reason is initialisation order, not a threshold. The four packed constants are built in theSoloReadBarcodeconstructor (SoloReadBarcode.cpp:16-21) frompSolo.umiL, but that path does not learn its UMI length until it has extracted one (SoloReadBarcode_getCBandUMI.cpp:353-354), so all four constants stay0. Poly-A packs to0and is still caught; poly-C/G/T pack non-zero and match nothing (:139). This is not new behaviour in this PR, rustar always rejected all four; what is new is that it is deliberate, locked byumi_valid_rejects_every_homopolymer, and written down.The rest of the diff
cbMinP = 0.975and theoneExactguard. STAR accepts a multi-match CB correction only when the posterior clears 0.975 (SoloReadBarcode_getCBandUMI.cpp,CB_MIN_P), and only when no exact whitelist match was available. The posterior itself already existed inresolve_multi_cb; the threshold and the guard did not, so a low-confidence correction was accepted where STAR drops the read.--soloCBtype. AcceptsSequence(default) andString.Adapter-anchored
CB_UMI_Complexgeometry, positions measured from the adapter match rather than from the read start, which is what--soloAdapterSequenceand--soloAdapterMismatchesNmaxare for.--soloOutFormatFeaturesGeneField3, the third column offeatures.tsv, with-suppressing it.One thing to flag rather than hide
CONTRIBUTING.mdsays one theme per PR. Five of the six commits are barcode and UMI resolution;--soloOutFormatFeaturesGeneField3isfeatures.tsvoutput, written by the same code path but arguably a different area. I have kept it here rather than split it because it is 51 lines and touches no shared logic, but say the word and I will lift it into its own PR.Verification
Unit tests in
src/solo/mod.rs:find_adapter_takes_the_best_scoring_match_not_the_leftmostfind_adapter_breaks_ties_leftmostan_n_in_the_adapter_is_not_a_mismatchIn
src/solo/whitelist.rs:umi_valid_rejects_every_homopolymer— all four bases, plus a UMI that merely starts with a run, plusNstill failing on its own groundsIn
src/solo/count.rs:cb_posterior_below_min_p_is_rejecteda_cb_never_seen_exactly_is_refused_without_pseudocountsGate on this branch, at the commit pushed: 570 lib + 26 integration tests,
cargo clippy --all-targets -- -D warnings,cargo fmt --check, MSRV 1.89 — all green.No new dependency.