Repository navigation
fix(consensus): apply --max-reads-per-strand cap, share combined-error Short cap, diagnose mi5252 (DUPLEX3-01/04) - #563
Conversation
WalkthroughThe change aligns depth and error aggregation with fgbio ChangesConsensus calling updates
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant consensus_call
participant downsample_source_reads
participant create_consensus_from_source_reads
participant VanillaConsensusRead
consensus_call->>downsample_source_reads: select max_reads scoring subset
downsample_source_reads-->>consensus_call: deterministic retained reads
consensus_call->>create_consensus_from_source_reads: compute consensus from subset
create_consensus_from_source_reads-->>consensus_call: consensus metrics
consensus_call->>VanillaConsensusRead: retain full source_reads with capped consensus
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #563 +/- ##
==========================================
- Coverage 92.89% 92.87% -0.03%
==========================================
Files 167 167
Lines 102777 102895 +118
==========================================
+ Hits 95479 95563 +84
- Misses 7298 7332 +34 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
2ccd518 to
293da5f
Compare
293da5f to
12cfa34
Compare
80292cb to
14a1cd8
Compare
12cfa34 to
db8af77
Compare
db8af77 to
ae0cd02
Compare
c3131c5 to
11dff2d
Compare
ae0cd02 to
9630ad9
Compare
11dff2d to
e268c6c
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/fgumi-consensus/src/codec_caller.rs`:
- Around line 3224-3330: Add programmatically generated fgbio-baseline
regression coverage at crates/fgumi-consensus/src/codec_caller.rs lines
3224-3330 and crates/fgumi-consensus/src/duplex_caller.rs lines 4991-5139.
Extend the existing codec and duplex tests to compare scalar depth/error tags
and capped per-base arrays against generated fgbio output for values 32767,
32768, and 65535, asserting identity or documenting any intentional divergence;
the requested changes apply at both sites.
- Around line 1179-1188: Update the duplex_error construction in the caller
logic so ea + eb and disagreement-branch additions occur in a wider integer
type, then saturate the intermediate result to fgbio’s Short ceiling before
converting to the final type. Preserve the existing cE/error-cap behavior while
preventing debug overflow and release wrapping for large u16 inputs; leave the
duplex_depth calculation unchanged.
In `@crates/fgumi-consensus/src/vanilla_caller.rs`:
- Around line 822-838: Add coverage for downsampling in downsample_source_reads
by either implementing an fgbio-compatible shuffle or creating a programmatic
differential test that compares capped-family consensus bases, qualities, and
depths against generated fgbio output. Ensure the test explicitly documents and
asserts the intended divergence if the existing StdRng behavior remains,
including cases where max_reads truncates the source reads.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 71635712-9e61-42a5-b049-2f43fa3f5bb6
📒 Files selected for processing (5)
crates/fgumi-consensus/Cargo.tomlcrates/fgumi-consensus/src/caller.rscrates/fgumi-consensus/src/codec_caller.rscrates/fgumi-consensus/src/duplex_caller.rscrates/fgumi-consensus/src/vanilla_caller.rs
… (DUPLEX3-01/04) DUPLEX3-01: --max-reads-per-strand was a silent no-op on the duplex (and codec) single-strand path. fgbio caps the reads contributing to a single-strand consensus inside consensusCall (shuffle.take(maxReads), VanillaUmiConsensusCaller.scala:288). fgumi's duplex/codec callers reach the single-strand consensus through VanillaUmiConsensusCaller::consensus_call, which never downsampled, so with the flag set fgumi used every read and emitted higher per-strand depth/quality than fgbio while the cap was ignored. Apply the cap inside consensus_call via a new downsample_source_reads helper (the SourceRead analogue of downsample_reads), shuffling with the caller's seeded RNG and truncating to max_reads. The vanilla path caps raw records earlier in process_group and does not route through consensus_call, so it is unchanged; when the flag is unset the cap is a no-op, preserving byte-identical default output. Verified end-to-end: on a deep synthetic duplex BAM, aD/bD reached 15/19 without the flag and are capped at 3 with --max-reads-per-strand 3, matching fgbio (which also caps at 3). DUPLEX3-04: the disabled test_mi5252_real_data recorded fgbio->C vs fgumi->N at R1 position 111. Investigated and found the divergence is a near-tie floating-point ordering artifact, not a consensus bug. fgumi's tie rule is faithful to fgbio's (no-call when a competing likelihood is within one machine epsilon of the maximum; both use epsilon = 2^-52 and both let a strictly-greater value clear the tie). At this locus the two contending bases are equal to within epsilon, so whether one wins uniquely (C) or is flagged a within-epsilon tie (N) depends on the order in which per-base likelihoods are accumulated. fgumi uses SIMD-vectorized Kahan summation over reads ordered by its filter_to_most_common_alignment port; fgbio uses scalar Kahan summation over its own ordering, and the two differ in the last ULP. This is not a correctness contract (fgbio PR #1120 exists precisely because these near-ties are numerically unstable), so the consensus logic is deliberately left unchanged. Remove the dead test (obsolete bam::Reader API, external committed-BAM dependency that no longer exists, violates the generate-test-data-programmatically convention) and replace it with a precise root-cause writeup; the live synthetic coverage of the same near-tie ordering mechanism is test_tie_breaking_for_simplex_consensus, whose stale contradictory doc comment (claimed N, asserted A) is corrected to match reality.
9630ad9 to
e64c215
Compare
The CODEC caller's per-base combined duplex error was `ea + eb` (and the
disagreement-branch sums) on `u16`. This is a defensive overflow concern, not a
reachable production bug: both the codec and duplex callers build their
single-strand consensuses through `VanillaUmiConsensusCaller::consensus_call`,
which already caps each per-base depth and error at fgbio's `Short` ceiling
(32767) at push, so every strand term is <= 32767 and the combined sum is
<= 65534 (fits u16). Still, a raw `ea + eb` on `u16` would panic in debug (and
wrap in release) on any above-ceiling strand value, so the combine step should
saturate rather than silently assume the upstream invariant.
Introduce `clamp_combined_error_to_fgbio_short` in `caller.rs` (beside
`clamp_per_base_to_fgbio_short`): sum the two strands' per-base errors in a wide
signed type, then saturate to `[0, 32767]`, matching fgbio's per-base `errors`
`Array[Short]`. Use it at every combine site so the cap is single-sourced:
- CODEC `build_duplex_consensus_from_padded` (agreement, both disagreement
branches, and the neither-has-data branch) -- previously an uncapped `u16`
sum.
- Duplex `build_duplex_consensus` (source-read and approximate methods) and
the `call_duplex_from_ss_pair` test helper -- previously an inline
`.clamp(0, i16::MAX)`, now the shared helper (behavior-identical).
The simplex/vanilla caller has no combine step; it caps each per-base error at
push (`vanilla_caller.rs:1417`), which is the upstream invariant this helper
defends. The downstream `cE` numerator re-clamps to the same ceiling, so this
changes only the stored intermediate, never the emitted tag.
Adds `test_duplex_per_base_error_caps_before_summing`, a defensive regression
feeding an above-ceiling (`errors = [40000, 40000]`) single-strand pair -- a
contract-level input the real pipeline does not produce, but one a raw `u16`
sum panics on in debug (verified by reverting the cap).
e64c215 to
40f8450
Compare
Summary
Three consensus findings from the final-audit burn-down (W10d):
--max-reads-per-strandwas a silent no-op on the duplex/codec single-strand path.C-vs-Noutput divergence; investigated, root-caused as a near-tie floating-point ordering artifact (no consensus-logic change), and replaced with a precise writeup.u16(a defensive overflow concern); introduce one sharedclamp_combined_error_to_fgbio_shorthelper and route every combine site (codec + duplex) through it.Merge status: rebased onto
main— the base consensus PR #552 (nh/fix-consensus-depth-clamp-parity) is now merged, so this branch targetsmaindirectly and inherits #552'sfgbio_oracle_saturation_tests.DUPLEX3-01 —
--max-reads-per-strandwas a silent no-op on the duplex pathfgbio caps the reads contributing to a single-strand consensus inside
consensusCall(shuffle.take(maxReads),VanillaUmiConsensusCaller.scala:288). fgumi's duplex (and codec) callers reach the single-strand consensus throughVanillaUmiConsensusCaller::consensus_call, which never downsampled — so with the flag set fgumi used every read and emitted higher per-strand depth/quality than fgbio while the cap was silently ignored. (The vanilla/simplex path caps raw records earlier inprocess_groupand does not route throughconsensus_call, so it was never affected.)The fix applies the cap inside
consensus_callvia a newdownsample_source_readshelper (theSourceReadanalogue of the existingdownsample_reads), shuffling with the caller's seeded RNG and truncating tomax_reads— exactly where fgbio caps. Two invariants preserved:source_readsare retained on the output — the cap shapes only the single-strand consensus bases/quals/depths; fgbio passes the pre-capfilteredAbR1s ++ filteredBaR2stoduplexConsensus, so the duplex caller (which counts per-base errors and calls the consensus UMI overoutput.source_reads) must still see every read. Capping it would undercount duplex errors and bias thecE/error-rate tags.A degenerate cap (
max_reads = 0, or belowmin_reads) now returns no consensus instead of panicking / hitting the empty-source bail.Reproduction (before → after)
On a synthetic deep duplex grouped BAM (per-strand read pairs up to 19), reading the per-strand depth tags
aD/bD:--max-reads-per-strand 3--max-reads-per-strand 3Before the fix the flag left
aD/bDat full depth (the added unit test failed asserting depth 8 withmax_reads=3); after the fix per-strand depth is capped, matching fgbio's contract. fgbio uses a randomshuffle.takeoverjava.util.Random(an LCG) while fgumi usesStdRng(ChaCha), so exact base/qual parity under downsampling is a documented, intentional divergence (seedownsample_source_reads) — the contract is "≤ N reads per strand contribute", and determinism-per-seed is pinned in tests.Combined-error Short cap — single-source the combine-step error clamp
The CODEC caller's per-base combined duplex error was
ea + eb(and the disagreement-branch sums) onu16. This is a defensive overflow concern, not a reachable production bug: both the codec and duplex callers build their single-strand consensuses throughVanillaUmiConsensusCaller::consensus_call, which already caps each per-base depth and error at fgbio'sShortceiling (32767) at push (vanilla_caller.rs:1413/1417). So every strand term feeding a combine step is ≤ 32767 and the combined sum is ≤ 65534 (fitsu16) for real pipeline data. Still, a rawea + ebonu16would panic in debug (and wrap in release) on any above-ceiling strand value, so the combine step should saturate rather than silently assume the upstream invariant.Introduce one shared
clamp_combined_error_to_fgbio_shorthelper incaller.rs(besideclamp_per_base_to_fgbio_short) — sum the two strands' per-base errors in a wide signed type, then saturate to[0, 32767], matching fgbio's per-baseerrorsArray[Short]— and route every combine site through it:build_duplex_consensus_from_padded— agreement, both disagreement branches, and the neither-has-data branch (previously an uncappedu16sum).build_duplex_consensus(source-read + approximate methods) and thecall_duplex_from_ss_pairtest helper — previously an inline.clamp(0, i16::MAX), now the shared helper (behavior-identical, confirmed by thefgbio_oracle_saturation_tests).The simplex/vanilla caller has no combine step; it caps each per-base error at push, which is the upstream invariant this helper defends. The downstream
cEnumerator re-clamps to the same ceiling, so this changes only the stored intermediate, never the emitted tag.DUPLEX3-04 — disabled test hid an output divergence: investigated, root-caused, documented (no consensus-logic change)
test_mi5252_real_datawas#[ignore]/commented ("bam::Reader issue") and recorded fgbio→Cvs fgumi→Nat R1 position 111 on real data. Investigation outcome: the divergence is a near-tie floating-point ordering artifact, not a consensus bug, so the consensus logic is deliberately left unchanged.N) when a competing likelihood is within one machine epsilon of the maximum. fgumi usesabs_diff_eq!(ll, max, epsilon = f64::EPSILON)(base_builder.rs::call); fgbio usesMathUtil.maxWithIndex(requireUniqueMaximum = true, epsilon = 1/2^52)(ConsensusCaller.call). Both epsilons are 2^-52 and both let a strictly-greater value clear the tie — same semantics, same order-sensitivity.C) or is flagged a within-epsilon tie (N) depends on the order in which per-base likelihoods are accumulated. fgumi accumulates with SIMD-vectorized Kahan summation over reads ordered by its port of fgbio'sfilterToMostCommonAlignment; fgbio accumulates with scalar Kahan summation over its own ordering. The two differ in the last ULP — enough to flip a unique max into a tie at this locus.N(no-call at a genuine tie) is the conservative, defensible outcome. Changing the SIMD summation order, the epsilon, or the read ordering to match fgbio at this one locus would be a speculative edit to shared consensus scoring that risks the unit tests pinning exact fgbio numbers elsewhere.The dead test is removed (obsolete
bam::ReaderAPI, a committed-BAM dependency that no longer exists, and it violated the generate-test-data-programmatically convention) and replaced with a precise root-cause writeup. The live synthetic coverage of the same near-tie ordering mechanism istest_tie_breaking_for_simplex_consensus, whose stale, self-contradictory doc comment (claimedN, assertedA) is corrected to match reality.Tests
test_consensus_call_caps_reads_per_strand— fails at depth 8 before the DUPLEX3-01 fix, passes at depth 3 after.test_duplex_per_base_error_caps_before_summing— a defensive regression feeding an above-ceiling (errors = [40000, 40000]) single-strand pair (a contract-level input the real pipeline does not produce); panics on debug overflow with a rawu16sum, asserts[32767, 32767]with the cap (verified by temporarily reverting it).test_consensus_call_max_reads_boundaries(rstest): cap-above / cap-equals / cap-below / no-cap / zero-cap boundaries, including the degeneratemax_reads = 0no-crash path.consensus_call_downsampling_is_deterministic_for_a_fixed_seed+consensus_call_cap_invariants(proptest): determinism-per-seed and the cap/full-source-retention invariants across arbitrary sizes/caps/min_reads.fgbio_oracle_saturation_tests(codec + duplex) pin depth/error tags against values captured from a real fgbio 4.0.1 run at the 32767/65534 saturation boundary and an open-interval mixed-strand case.cargo ci-fmt+cargo ci-lintclean.Summary by CodeRabbit