Skip to content

fix(umi): account for unmapped reads rejected from consensus calling - #1169

Draft
nh13 wants to merge 1 commit into
nh_test-consensus-cap-half-mapped-pairfrom
nh_account-for-unmapped-consensus-rejects
Draft

nh13 wants to merge 1 commit into
nh_test-consensus-cap-half-mapped-pairfrom
nh_account-for-unmapped-consensus-rejects

Conversation

@nh13

@nh13 nh13 commented Aug 10, 2026

Copy link
Copy Markdown
Member

Stacked on #1168 — base is nh_test-consensus-cap-half-mapped-pair, so review that one first. The diff here is the third commit only.

Problem

#1168 stopped unmapped reads contributing to consensus reads by dropping them in ConsensusCallingIterator's pre-group filter. That fixed the defect but made the reads invisible: they were never counted in raw_reads_considered, carried no rejection reason, and never reached --rejects.

A read that disappears between input and output with nothing in the metrics to explain it is hard to tell apart from a bug. It also sits awkwardly next to every other way a read can be excluded from a consensus, all of which are counted and emitted.

Fix

Move the decision from the iterator into UmiConsensusCaller.consensusReadsFromSamRecords — the final method every caller funnels through — and reject via a new RejectionReason.Unmapped:

val (mapped, unmapped) = recs.partition(_.mapped)
if (unmapped.nonEmpty) rejectRecords(unmapped, RejectionReason.Unmapped)

Structural coverage of all three tools is preserved, and because the rejection now happens on the caller, the counts aggregate through addStatistics exactly as every other reason does — including under --threads > 1, where each thread has its own caller via emptyClone().

The iterator's predicate returns to mapped || (paired && mateMapped). Templates with no mapped read at all are still dropped there, unchanged; only the unmapped end of a half-mapped pair now flows through to be rejected explicitly.

The reason is marked as used by all three callers (usedByVanilla/usedByDuplex/usedByCodec all true), so raw_reads_rejected_for_unmapped is initialised to zero and appears in every stats file rather than materialising only when it fires.

Behaviour

For input containing half-mapped pairs, relative to #1168:

  • raw_reads_considered counts the unmapped read again, as it did before fix(umi): exclude unmapped reads from consensus calling #1168
  • raw_reads_rejected_for_unmapped is a new key in the stats output
  • the unmapped read appears in --rejects, tagged with the rr rejection-reason tag like any other rejected record

No change to which reads contribute to a consensus — #1168 settled that, and its tests still pin it.

Tests

CallMolecularConsensusReadsTest's two cases are tightened from a permissive check to exact assertions: the precise rejects content, the new raw_reads_rejected_for_unmapped count, and raw_reads_considered. The permissive form was deliberate in #1168 so it would hold under either accounting choice; now that the choice is made, it pins it.

Full suite: 1608 tests, 0 failures.

Note on fgumi parity

I originally raised this as an fgbio-vs-fgumi asymmetry. That framing was not quite right and is worth stating plainly: fgumi's default path also drops unmapped reads silently, in consensus_pregroup_keep_raw, which is a plain record filter that records nothing. Its RejectionReason::Unmapped fires only on the --allow-unmapped path.

So this change is not restoring parity — it puts fgbio ahead, and the equivalent accounting on the fgumi default path (fulcrumgenomics/fgumi#737) is now the gap. Worth doing there too, but it does not belong in that PR either.

Reject unmapped reads inside the caller instead of filtering them out in
ConsensusCallingIterator, so they are counted and written to `--rejects` rather
than disappearing without trace.

The parent commit dropped unmapped reads in the iterator's pre-group filter. That
fixed the defect but made the reads invisible: they were never counted in
`raw_reads_considered`, carried no rejection reason, and never reached the rejects
output. A read that vanishes between input and output with nothing in the metrics
to explain it is hard to distinguish from a bug.

Move the decision into `UmiConsensusCaller.consensusReadsFromSamRecords`, the final
method every caller funnels through, and reject via a new
`RejectionReason.Unmapped`. All three tools are still covered by construction, and
because the rejection happens on the caller the counts aggregate through
`addStatistics` exactly as every other reason does, including under `--threads > 1`.

The iterator's predicate returns to `mapped || (paired && mateMapped)`. Templates
with no mapped read at all are still dropped there, unchanged; only the unmapped
end of a half-mapped pair now flows through to be rejected explicitly.

`CallMolecularConsensusReadsTest` is tightened accordingly: it now pins the exact
rejects content, the new `raw_reads_rejected_for_unmapped` count, and
`raw_reads_considered`, which counts the unmapped read again now that the caller
sees it.

Full suite: 1608 tests, 0 failures.
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.95%. Comparing base (3bc4526) to head (2a294c0).

Additional details and impacted files
@@                           Coverage Diff                           @@
##           nh_test-consensus-cap-half-mapped-pair    #1169   +/-   ##
=======================================================================
  Coverage                                   95.95%   95.95%           
=======================================================================
  Files                                         132      132           
  Lines                                        8347     8349    +2     
  Branches                                      952      940   -12     
=======================================================================
+ Hits                                         8009     8011    +2     
  Misses                                        338      338           
Flag Coverage Δ
unittests 95.95% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
PR Preview Action v1.6.1

🚀 View preview at
https://fulcrumgenomics.github.io/fgbio/pr-preview/pr-1169/

Built to branch gh-pages at 2026-08-10 17:37 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

This branch has not been deployed

No deployments
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