Skip to content

fix(consensus)!: make consensus read downsampling deterministic - #727

Merged
nh13 merged 2 commits into
mainfrom
725/nhomer/fix-deterministic-consensus-downsampling
Aug 10, 2026
Merged

nh13 merged 2 commits into
mainfrom
725/nhomer/fix-deterministic-consensus-downsampling

Conversation

@nh13

@nh13 nh13 commented Aug 8, 2026 •

Copy link
Copy Markdown
Member

Problem

Consensus downsampling selected which reads to keep by shuffling with a seeded StdRng held on the consensus caller:

reads.shuffle(&mut self.rng);
reads.truncate(max_reads);

The RNG is stateful, so which reads survive a tag family depends on how many families that caller instance has already downsampled.

fgumi does not have fgbio's thread-count bug. The unified pipeline builds a fresh caller per batch at a fixed batch size, so --threads 1 and --threads 8 already agreed — I verified this byte-for-byte. But the no---threads path holds a single caller for the entire run (simplex.rs:378, duplex.rs:420), putting its RNG at a different position for every family. The two execution modes therefore disagreed under a cap:

  • duplex --max-reads-per-strand 2: 566 of 77,804 consensus records differed (0.73%) between --threads N and no---threads
  • simplex --max-reads 2: differed
  • with no cap, both modes were byte-identical — the divergence is cap-specific

Two further consequences of RNG-based selection:

  • Both ends of a template were downsampled independently. duplex calls consensus_call once per strand per end (duplex_caller.rs:1992-1995), so R1 and R2 sampled different templates. Beyond reproducibility, this silently lost reads: families whose split left one end empty produced no consensus pair at all.
  • Selection could not match fgbio. fgumi shuffles with StdRng (ChaCha); fgbio uses java.util.Random (an LCG). Against fgbio 4.1.0 at --max-reads-per-strand 2, 487 of 77,804 duplex records (0.63%) differed in SEQ/QUAL. Depth distributions matched exactly, so selection was the only difference. This was already documented as a known limitation in downsample_source_reads.

Fix

Rank reads by a Murmur3 hash of the read name and keep the lowest-ranking ones, porting fulcrumgenomics/fgbio#1166. The retained subset becomes a pure function of the family — independent of caller history, execution mode, and arrival order — and because mates share a read name they share a rank, so a template is retained or discarded as a unit.

fgumi already contained a byte-exact port of htsjdk's Murmur3.hashUnencodedChars, validated against reference vectors captured from htsjdk 3.1.2. It moves from the root crate down into fgumi-raw-bam, where the consensus callers can reach it, alongside the samtools-parity read-name comparator already living there.

Three details are load-bearing for parity, and each has a dedicated test:

  • The rank is computed once per read and stored on SourceRead / ClippedRecordInfo, never inside a sort key closure. Rust's sort_by_key may evaluate its closure more than once per element, so hashing there would re-hash on every comparison — the same defect the review of fgbio#1166 flagged in its Scala (25–30× the element count at n=100k).
  • Ranks compare as signed i32, matching fgbio's Scala Int. About half of all read names hash negative, so an unsigned comparison silently reorders them.
  • The sort is stable, matching Scala's sortInPlaceBy, so tied ranks keep input order.

Selection now lives in one place, caller::select_lowest_ranking, shared by the raw-record and codec paths. With no RNG left on the caller there is no per-instance state for downsampling to depend on, so the seed option and the rand dependency are removed — the determinism is structural rather than incidental.

Behaviour changes

1. Which reads are retained differs from previous releases for any run that sets --max-reads, --max-reads-per-strand, or codec --max-reads. There is no way to fix the reproducibility bug while preserving the old selection, since the old selection was the bug. Runs without a cap are byte-identical, verified against a pre-change binary.

2. Both ends of a template are now retained or discarded together, which also recovers reads the old shuffle lost. On a 77k-molecule library:

reads emitted cD distribution
before 726,854 1: 726,854
after 734,536 1: 734,536
fgbio 4.1.0 734,536 1: 702,047 · 2: 32,489

The count now matches fgbio. The depth does not, because simplex still applies the cap to the whole tag family rather than to each end — tracked separately as #723, and deliberately not fixed here.

3. duplex --max-reads-per-strand 0 is now rejected (second commit). It previously exited 0 having written an empty BAM.

Tests

Every selection test was verified red under a mutant that breaks the property it claims to guard, not merely green afterward:

  • the subset is independent of how many families the caller downsampled previously
  • the subset is invariant to input read order
  • signed comparison — mutating to wrapping_abs (the abs form that exists elsewhere in this repo) fails it
  • stability — mutating to sort_unstable_by_key fails it, using mixed tied/distinct keys at n=30, since all-equal keys can never separate stable from unstable
  • both ends of a template retained together — a hash(name + end) mutant fails exactly this test and nothing else
  • a golden test pinning which reads are retained, derived independently from htsjdk Murmur3(42) by running real htsjdk. Without it, a deterministic-but-biased rule passes everything above — sorting by read name, which on Illumina names sorts by tile/x/y, would systematically favour one region of the flowcell.

Plus the regression test for the bug that is actually live in fgumi: capped output must be identical across --threads 1, --threads 8, and no---threads, for simplex and duplex. Verified failing at e2ad27bf — both capped cases fail, both uncapped cases pass.

Full suite: 7182 passed, 30 skipped.

Deliberately not included

No fgbio byte-parity oracle. fgbio#1166 is still open, so there is nothing stable to pin against; a fixture generated from released 4.1.0 would disagree by design, since it still uses the shuffle. Once that PR merges, this change also restores byte parity under a cap, and the oracle can follow. This is a known gap, not an oversight.

Also out of scope, both filed: #723 (simplex caps the whole MI group instead of each end) and #724 / fulcrumgenomics/fgbio#1167 (reads dropped by downsampling are counted as used in raw_reads_used and never reach --rejects — pre-existing and shared with fgbio).

Reading order

Two commits, mirroring fgbio#1166's structure. Both build and test standalone.

  1. fix(consensus)!: make consensus read downsampling deterministic — start at crates/fgumi-raw-bam/src/hash.rs (the ranking primitive), then caller::select_lowest_ranking, then the three call sites in vanilla_caller.rs and codec_caller.rs.
  2. fix(duplex): reject --max-reads-per-strand 0 — reviewable independently.

Closes #725

Risk: output changes for capped consensus runs, pinned by signed fgbio-compatible read-name hashes and stable pair selection; unsafe changes: none, and CLAUDE.md allowlist changes: none; memory bounds, queue capacity, and thread/backpressure policy changes: none.

  • Replaced seeded random downsampling with deterministic lowest signed Murmur3 read-name ranks.
  • Preserved input-order stability and retained paired ends together.
  • Applied shared selection logic to raw-record, vanilla, and CODEC paths.
  • Removed the obsolete RNG and seed option.
  • Added fgbio-compatible UTF-16 Murmur3 hashing and public hash helpers.
  • Added unit, property-based, and integration coverage across execution modes, ordering, ties, pairing, hash vectors, and cap boundaries.
  • Rejected --max-reads-per-strand 0.
  • Updated command documentation.
  • Uncapped runs remain byte-identical. Capped runs can retain different reads.

@nh13
nh13 temporarily deployed to github-actions August 8, 2026 03:36 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6a67b88e-6844-4601-9527-b2ee6a39af88

📥 Commits

Reviewing files that changed from the base of the PR and between 3d83a55 and 00bba7b.

⛔ Files ignored due to path filters (2)
  • CHANGELOG.md is excluded by !**/CHANGELOG.md
  • Cargo.lock is excluded by !**/*.lock, !**/*.lock
📒 Files selected for processing (14)
  • crates/fgumi-consensus/Cargo.toml
  • crates/fgumi-consensus/src/caller.rs
  • crates/fgumi-consensus/src/codec_caller.rs
  • crates/fgumi-consensus/src/duplex_caller.rs
  • crates/fgumi-consensus/src/methylation.rs
  • crates/fgumi-consensus/src/vanilla_caller.rs
  • crates/fgumi-raw-bam/src/hash.rs
  • crates/fgumi-raw-bam/src/lib.rs
  • src/lib/commands/codec.rs
  • src/lib/commands/duplex.rs
  • src/lib/commands/shared_metrics.rs
  • src/lib/commands/simplex.rs
  • tests/integration/main.rs
  • tests/integration/test_consensus_downsampling.rs
💤 Files with no reviewable changes (2)
  • crates/fgumi-consensus/src/duplex_caller.rs
  • crates/fgumi-consensus/Cargo.toml

Walkthrough

Consensus downsampling now uses deterministic fgbio-compatible read-name hash ranks. Vanilla, CODEC, simplex, and duplex workflows remove seeded RNG behavior, preserve pair semantics, validate zero caps, and add unit and integration coverage.

Changes

Deterministic consensus downsampling

Layer / File(s) Summary
Shared hashing contract
crates/fgumi-raw-bam/src/hash.rs, crates/fgumi-raw-bam/src/lib.rs, src/lib/commands/shared_metrics.rs
Adds htsjdk Murmur3 and fgbio read-name ranking. Shared metrics uses the exported implementation.
Vanilla rank-based selection
crates/fgumi-consensus/src/caller.rs, crates/fgumi-consensus/src/vanilla_caller.rs, crates/fgumi-consensus/src/duplex_caller.rs, crates/fgumi-consensus/src/methylation.rs
Replaces random selection with stable signed-rank selection. Stores ranks on SourceRead, removes the seed option, and retains full source reads.
CODEC paired-read integration
crates/fgumi-consensus/src/codec_caller.rs
Selects paired reads by hash rank, preserves R1/R2 alignment, and delegates uncapped single-strand consensus.
Command validation and workflow coverage
src/lib/commands/*.rs, tests/integration/*
Updates capping help text, rejects zero duplex strand caps, and tests deterministic simplex and duplex output across thread modes.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Command
  participant ConsensusCaller
  participant fgbio_read_name_rank
  participant select_lowest_ranking
  participant BAMOutput
  Command->>ConsensusCaller: run with read cap
  ConsensusCaller->>fgbio_read_name_rank: hash read names
  fgbio_read_name_rank-->>ConsensusCaller: signed ranks
  ConsensusCaller->>select_lowest_ranking: ranks and cap
  select_lowest_ranking-->>ConsensusCaller: retained indices
  ConsensusCaller->>BAMOutput: write consensus records
Loading

Possibly related issues

Possibly related PRs

Suggested labels: raw-bam

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commit format and clearly describes the deterministic consensus downsampling change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@nh13

nh13 commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai pause

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews paused.

@codecov

codecov Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.89503% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.12%. Comparing base (e2ad27b) to head (00bba7b).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
src/lib/commands/shared_metrics.rs 50.00% 2 Missing ⚠️
crates/fgumi-consensus/src/codec_caller.rs 98.97% 1 Missing ⚠️
crates/fgumi-consensus/src/vanilla_caller.rs 99.46% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #727      +/-   ##
==========================================
+ Coverage   94.03%   94.12%   +0.08%     
==========================================
  Files         178      181       +3     
  Lines      108899   109415     +516     
==========================================
+ Hits       102408   102982     +574     
+ Misses       6491     6433      -58     

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

nh13 added 2 commits August 8, 2026 21:55
Downsampling selected which reads to keep by shuffling with a seeded StdRng held
on the consensus caller. The RNG is stateful, so the surviving subset for a tag
family depended on how many families that caller instance had already processed.

fgumi did not have fgbio's thread-count bug: the unified pipeline builds a fresh
caller per batch at a fixed batch size, so --threads 1 and --threads 8 agreed.
But the no---threads path holds a single caller for the entire run, putting its
RNG at a different position for every family, so the two execution modes
disagreed under a cap. On a 77k-molecule library, duplex output at
--max-reads-per-strand 2 differed on 566 of 77,804 records (0.73%). Uncapped runs
always agreed.

Rank reads by a Murmur3 hash of the read name and keep the lowest-ranking ones.
The retained subset becomes a pure function of the family: independent of caller
history, execution mode, and the order reads arrive in. Because both ends of a
template share a read name they share a rank, so a template is retained or
discarded as a unit -- which also recovers reads the old shuffle lost by
splitting templates across ends (simplex --max-reads 2 on that library goes from
726,854 to 734,536 consensus reads, matching fgbio's count).

The rank is computed once per read and stored, never inside a sort key closure:
Rust's sort_by_key may evaluate its closure more than once per element, so
hashing there would re-hash on every comparison. Ranks compare as signed i32,
matching fgbio's Scala Int, and the sort is stable so tied ranks keep input
order. Selection now lives in one place, caller::select_lowest_ranking, shared
by the raw-record and codec paths.

With no RNG left on the caller there is no per-instance state for downsampling to
depend on, so the seed option and the rand dependency are removed and the
determinism becomes structural rather than incidental.

Which reads are retained differs from previous releases for any run that sets a
cap. There is no way to fix the reproducibility bug while preserving the old
selection, since the old selection was the bug. Runs without a cap are
byte-identical, verified against a pre-change binary.

Ports fulcrumgenomics/fgbio#1166. Once that merges, this also restores byte
parity with fgbio under a cap; a parity oracle test is deliberately deferred
until then rather than pinned to the 4.1.0 shuffle it would disagree with.

Closes #725
A cap of zero empties every strand, so no molecule can produce a consensus, and
fgumi exited 0 having written an empty BAM. simplex and codec already validate
their equivalents.

Only a lower bound is checked, matching fgbio: duplex's --min-reads is a vector
with its own ordering rule, so the max >= min comparison the sibling tools make
does not apply, and a cap below it is already handled gracefully -- consensus_call
returns no consensus rather than panicking. A negative value is rejected by clap,
since the option is an Option<usize>, so validate() only has to cover zero; fgbio
needs the wider check because its option is an Option[Int].

Ports the second commit of fulcrumgenomics/fgbio#1166.

Refs #725
@nh13
nh13 force-pushed the 725/nhomer/fix-deterministic-consensus-downsampling branch from cf25828 to 00bba7b Compare August 9, 2026 05:12
@nh13
nh13 temporarily deployed to github-actions August 9, 2026 05:12 — with GitHub Actions Inactive
@nh13
nh13 marked this pull request as ready for review August 9, 2026 17:44
@nh13

nh13 commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13
nh13 merged commit ade4853 into main Aug 10, 2026
16 checks passed
@nh13
nh13 deleted the 725/nhomer/fix-deterministic-consensus-downsampling branch August 10, 2026 13:47
nh13 added a commit that referenced this pull request Aug 18, 2026
…785)

Reads discarded by the `--max-reads` consensus-downsampling cap were counted as used and never reached `--rejects`. Because `raw_reads_used = total_input_reads - filtered_reads` and only a recorded rejection increments `filtered_reads`, a family of 10 capped to 3 reported `raw_reads_used = 10` against a consensus of depth 3, and the 7 discarded reads were dropped silently.

Add a `Downsampled` rejection reason and record the discarded reads at each drop site so `raw_reads_used` excludes them:

- simplex (`VanillaUmiConsensusCaller::process_group`): `downsample_reads` now returns the discarded records alongside the survivors; they are counted and, when rejects tracking is enabled, routed to the `--rejects` output byte-for-byte in input order.
- codec (`CodecConsensusCaller`): the per-strand cap counts the discarded reads, mirroring how the codec caller already counts its other per-read rejections (e.g. MinorityAlignment). Per-read routing to `--rejects` for the codec waits on its reject-mask machinery (#751), so this only fixes the count there.

The duplex/codec per-strand `consensus_call` path is unaffected: since #727 it retains the full uncapped source reads (the cap shapes only the consensus bases/quals/depths), so no reads are dropped there.

`Downsampled` is fgumi-specific: fgbio has no equivalent metric yet (fulcrumgenomics/fgbio#1166, #1167), so `raw_reads_used` and the new `raw_reads_rejected_for_downsampled` row diverge from fgbio under a cap. The new row is emitted only when non-zero.

This branch was previously deployed

1 inactive deployment
github-actions — 00bba7b5 Deployed Aug 9, 2026 by nh13 via coverage #3391
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.

Consensus downsampling is not reproducible across execution modes and cannot match fgbio

1 participant