Skip to content

fix(consensus): handle NaN likelihoods like fgbio - #1029

Merged
nh13 merged 1 commit into
mainfrom
nh/ln-sum-exp-nan
Oct 8, 2026
Merged

nh13 merged 1 commit into
mainfrom
nh/ln-sum-exp-nan

Conversation

@nh13

@nh13 nh13 commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

ln_sum_exp_array finds the minimum lane by starting at f64::INFINITY with index 0 and skipping NaN lanes. When no lane compared below that start value (every lane NaN or +inf), the sum was seeded with the +inf start value instead of lane 0, so a NaN in lane 0 was never folded in. [NaN] and [NaN, +inf] returned +inf, a plausible-looking log-probability, instead of NaN.

The sum is now seeded from the selected lane itself (values[min_index]), so any NaN lane yields NaN. This is a one-line change with no extra pass over the lanes on the consensus hot path. The rustdoc now states the contract: -inf for an empty or all--inf array, NaN whenever any lane is NaN.

This PR also fixes a sibling bug in ln_prob_to_phred: a NaN error probability passed through clamp and the as u8 cast turned it into Q0, below the documented [2, 93] range. It now returns MIN_PHRED (Q2), as fgbio does.

With that guard, a position where all four bases carry a -inf correct term (three lanes NaN, the last-added base's lane -inf) would be called under the default FgbioCompat rule as whichever base was added last, at Q2, so the base depended on read order. fgbio throws there. unique_max_index_with now no-calls a -inf maximum under FgbioCompat, as UlpRelative already did, which covers both call_full and the multi-base fast path; fgbio_unique_max_index stays an exact port.

Changes output for: positions with a -inf correct term on every observation, reachable only with --error-rate-post-umi 0 or with --min-input-base-quality 0 and Q0 input bases.

  • Three distinct bases: the single-strand quality goes from Q0 to Q2. Simplex with --min-consensus-base-quality 1 or 2 emits the base instead of N; duplex emits (T, Q4) instead of N when both strands are degenerate; codec emits (T, Q4) instead of (T, Q0) in the duplex region and N instead of (T, Q0) in a single-strand region. All match fgbio.
  • All four bases: (N, Q2) instead of the last-added base at Q0, which simplex at its default --min-consensus-base-quality 2 and duplex already masked to N but codec could emit. fgbio throws here.

Default settings (--min-input-base-quality 10, nonzero error rates) never produce a -inf term, so default output is unchanged.

When the NaN is reachable

The adjusted probability of a correct base is ln(0) = -inf whenever either side of the two-trial error is Q0 (error probability 1; ln_error_prob_two_trials returns the dominant ln(1) = 0, as fgbio's probabilityOfErrorTwoTrials does): --error-rate-post-umi 0 (simplex only; duplex and codec reject it, fgbio accepts it), or a Q0 input base kept by --min-input-base-quality 0 (simplex, duplex, codec). The Kahan update in ConsensusBaseBuilder::add then sets that lane's compensation to (-inf - sum) - -inf = NaN, and the next add turns the lane itself into NaN. fgbio's kahanAdd (ConsensusCaller.scala:128-133, added in fgbio #1120 / e2ccac9) does the same arithmetic, so this lane behavior is shared and is pinned here rather than changed.

Example: pre-UMI 45, observations A, C, G, with either post-UMI 0 and Q30 bases or post-UMI 40 and Q0 bases. The lanes end as [NaN, NaN, -inf, finite], T is the unique non-NaN maximum, and the final error probability is NaN.

call
fgbio e51a661 (T, Q2)
fgumi before (T, Q0)
fgumi after (T, Q2)

The ln_sum_exp_array change alone alters no consensus output: real likelihood lanes are never +inf, four all-NaN lanes already summed to NaN, and ConsensusBaseBuilder::call_full no-calls an all-NaN position in unique_max_index_with before the sum is used. The output changes are the Q0 -> Q2 quality above, its downstream effects, and the four-base no-call.

fgbio parity (e51a661)

  • NumericTypes.scala:167 (LogProbability.or(Array)): returns -inf when every lane is -inf (including empty), otherwise seeds the sum with MathUtil.minWithIndex and folds the other lanes in index order. fgumi matches the -inf guard and the fold.
  • MathUtil.scala:75-100 (minWithIndex): skips NaN and -inf lanes and throws NoSuchElementException when none remain. For those inputs (every lane NaN or -inf, at least one NaN) fgumi returns NaN instead of throwing. For any other array with a NaN lane, fgbio's or also returns NaN, so the two agree.
  • ConsensusCaller.scala:173: the quality is PhredScore.cap(PhredScore.fromLogProbability(p)). For NaN, fromLogProbability (NumericTypes.scala:83) returns Math.floor(NaN).toByte, which is 0, and cap (NumericTypes.scala:69) raises it to MinValue = 2. fgumi's ln_prob_to_phred folds the cap in, so it now returns Q2 for NaN.
  • Exhaustive check against a Scala replica of fgbio's ConsensusBaseBuilder: all 38,812 pileups of length 1-5 at pre-UMI 45 (post-UMI 40 with Q0/Q30 bases; post-UMI 0 with Q30 bases). All 37,804 cases fgbio calls match exactly; the 1,008 where fgbio throws NoSuchElementException are N/Q2 in fgumi.

Tests

  • test_ln_sum_exp_array_with_nan_and_no_finite_lane_is_nan (rstest):
    • [NaN] ports the row at MathUtilTest.scala:93 ("MathUtil.maxWithIndex should throw exceptions on invalid inputs", :91, whose row calls minWithIndex).
    • [-inf, NaN] ports the row at MathUtilTest.scala:57 ("MathUtil.minWithIndex should throw exceptions on invalid inputs", :54). [NaN, -inf] and [NaN, NaN] cover the lane-order variants.
    • [NaN, +inf] is fgumi's own: fgbio seeds with the +inf lane and or(+inf, NaN) gives NaN.
    • Only [NaN] and [NaN, +inf] failed before the fix; the other rows are regression guards. The empty and [-inf] rows (MathUtilTest.scala:55-56) never reach minWithIndex through or; test_ln_sum_exp_array_all_neg_inf_is_neg_inf already pins them.
  • test_ln_prob_to_phred_nan_is_min_phred (rstest, NaN and -NaN): asserts exactly Q2.
  • test_kahan_neg_inf_term_turns_lane_nan_on_next_add: pins the shared Kahan -inf -> NaN lane behavior with post-UMI Q0 (lane A is -inf after one add and NaN after the next).
  • test_degenerate_pileup_calls_min_phred_like_fgbio (rstest over both Q0 routes x three add orders x both tie rules, 12 cases): asserts call() and call_full() both return exactly (T, 2). Every case fails without the guard with (T, 0). fgbio has no test for this case; the expected value was derived from the fgbio e51a661 source above.
  • test_all_four_bases_with_neg_inf_correct_term_no_calls (rstest over both Q0 routes x five read orders x both tie rules, 20 cases): pins that the last-added base's lane is -inf and the rest NaN, and that call() and call_full() both return (N, Q2). The 10 FgbioCompat cases fail without the -inf no-call.
  • test_degenerate_position_emits_min_phred_base (vanilla caller, rstest over both Q0 routes): with --min-consensus-base-quality 2, the degenerate position is emitted as T at Q2; without the guard it was masked to N.
  • test_degenerate_position_on_both_strands_calls_duplex_t_at_q4 (duplex caller): AB and BA families with A/C/G at Q0 at one position give (T, Q4), derived from fgbio (DuplexConsensusCaller.scala:142, :424, :431; VanillaUmiConsensusCaller.scala:354). Without the guard it was (N, Q2).
  • test_degenerate_position_matches_fgbio (codec caller, rstest): (T, Q4) in the duplex region and (N, Q2) in a single-strand region, derived from fgbio (CodecConsensusCaller.scala:251-252 pads with Q0, then DuplexConsensusCaller.scala:424-431). Without the guard both were (T, Q0).

Mutation-checked: seeding from the start value again fails [NaN] and [NaN, +inf]. An all-NaN guard in its place (no other change) still fails [NaN, +inf]. Removing the ln_prob_to_phred NaN guard fails 19 tests (both NaN rows, all 12 degenerate-pileup cases, both vanilla cases, the duplex case, both codec cases). Removing the -inf no-call fails all 10 FgbioCompat four-base cases.

Performance

The NaN guard is one never-taken, perfectly predictable branch per ln_prob_to_phred call (no NaN arises at default settings), next to a division and a floor; the seed change replaces one load with another. Neither adds a pass over the lanes.

Checks

cargo ci-fmt, cargo ci-lint, cargo ci-doc, cargo ci-tag-literals, cargo nextest run --workspace (10323 passed, 31 skipped).

Risk: Consensus output changes for degenerate Q0 cases, pinned by regression tests; no unsafe changes, so the CLAUDE.md allowlist needs no update; no memory-bound, queue-capacity, or thread/backpressure policy changes.

NaN likelihoods now propagate through ln_sum_exp_array, and ln_prob_to_phred maps NaN to Q2. In FgbioCompat, an all--inf maximum now produces a no-call instead of an order-dependent base. These changes affect consensus output in edge cases, including positions reached with zero post-UMI error rate or Q0 minimum input quality. Tests pin the base and quality expectations in the vanilla, duplex, and CODEC callers.

Reported checks include cargo ci-fmt, cargo ci-lint, cargo ci-doc, cargo ci-tag-literals, and cargo nextest run --workspace (10,323 passed; 31 skipped). These results are author-reported; this inspection did not run tests.

ln_sum_exp_array starts its minimum search at f64::INFINITY with index
0 and skips NaN lanes. When every lane was NaN or +inf, the sum was
seeded with the +inf start value instead of lane 0, so [NaN] and
[NaN, +inf] returned +inf rather than NaN. The sum is now seeded from
the selected lane itself, so any NaN lane yields NaN. fgbio's
LogProbability.or (NumericTypes.scala:167) throws for those inputs
(MathUtilTest.scala:57, :93); fgumi returns NaN. This alone changes no
consensus output.

ln_prob_to_phred now maps NaN to MIN_PHRED (Q2). NaN passed through
clamp and the u8 cast as Q0, below the documented [2, 93] range; fgbio
computes PhredScore.cap(fromLogProbability(NaN)) = Q2
(ConsensusCaller.scala:173).

The NaN arises when an observation's correct term is ln(0) = -inf, which
takes a Q0 on either side of the two-trial error: --error-rate-post-umi
0 (simplex only) or a Q0 input base with --min-input-base-quality 0. The
Kahan update turns that lane NaN on the next add, as fgbio's kahanAdd
(ConsensusCaller.scala:128-133) does. Observations A, C, G leave lanes
[NaN, NaN, -inf, finite] and call (T, Q2), as in fgbio.

With all four bases observed, three lanes are NaN and the lane of the
last-added base is -inf. FgbioCompat selected that lane, so the called
base depended on read order; fgbio throws there. A -inf maximum is now
a no-call (N, Q2) under FgbioCompat too, as under UlpRelative, in the
selection shared by call_full and the multi-base fast path.
fgbio_unique_max_index stays an exact port. Over all 38,812 pileups of
length 1-5 (pre-UMI 45; post-UMI 40 with Q0/Q30 bases, post-UMI 0 with
Q30 bases), every case fgbio calls is unchanged and matches it; the
1,008 where fgbio throws are now N/Q2.

Changes output for: positions with a -inf correct term on every observation, reachable only with --error-rate-post-umi 0 or with --min-input-base-quality 0 and Q0 input bases. Three distinct bases: the single-strand quality goes from Q0 to Q2, so simplex with --min-consensus-base-quality 1 or 2 emits the base instead of N, duplex emits (T, Q4) instead of N when both strands are degenerate, and codec emits (T, Q4) instead of (T, Q0) in the duplex region and N instead of (T, Q0) in a single-strand region. All four bases: (N, Q2) instead of the last-added base at Q0, which simplex at its default --min-consensus-base-quality 2 and duplex already masked to N but codec could emit.

Adds tests porting the fgbio NaN rows, pinning ln_prob_to_phred(NaN)
at Q2, the shared Kahan -inf -> NaN behavior, the three-base pileup
over both Q0 routes, three add orders and both tie rules, the
four-base no-call over five read orders, and the degenerate position
through the simplex, duplex and codec callers.
@nh13
nh13 deployed to github-actions October 6, 2026 21:29 — with GitHub Actions Active
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Review in 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: Repository: fulcrumgenomics/fgumi/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: f667694d-0406-437d-ad69-a29699ed9a27
📥 Commits

Reviewing files that changed from the base of the PR and between 62b98c0 and 6bfc9dc.

📒 Files selected for processing (5)
  • crates/fgumi-consensus/src/base_builder.rs
  • crates/fgumi-consensus/src/codec_caller.rs
  • crates/fgumi-consensus/src/duplex_caller.rs
  • crates/fgumi-consensus/src/phred.rs
  • crates/fgumi-consensus/src/vanilla_caller.rs

Included review availability: This review used your included allowance. 3 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 4 reviews per hour.


Walkthrough

Degenerate Q0 likelihoods could produce order-dependent base calls or incorrect qualities. The changes preserve NaN in likelihood sums, map NaN to MIN_PHRED, and reject negative-infinity selections in FgbioCompat mode.

Changes

Degenerate likelihood handling

Layer / File(s) Summary
Likelihood and Phred edge handling
crates/fgumi-consensus/src/phred.rs
ln_prob_to_phred returns MIN_PHRED for NaN. ln_sum_exp_array preserves NaN when no lane is below positive infinity. Tests cover these cases.
Degenerate base selection
crates/fgumi-consensus/src/base_builder.rs
FgbioCompat rejects a selected lane with negative-infinity likelihood. Tests cover Kahan summation, degenerate pileups, and all-four-base no-calls under both tie rules.
Consensus caller regression coverage
crates/fgumi-consensus/src/codec_caller.rs, crates/fgumi-consensus/src/duplex_caller.rs, crates/fgumi-consensus/src/vanilla_caller.rs
Regression tests check bases and qualities for degenerate Q0 inputs across CODEC, duplex, and vanilla callers.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 6bfc9

The degenerate-likelihood changes are ready to merge after normal checks; no actionable issue remains identified.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title follows the required conventional-commit format. Its lowercase imperative description summarizes the NaN likelihood handling 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.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@nh13

nh13 commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai pause

@coderabbitai

coderabbitai Bot commented Oct 6, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews paused.

@codecov

codecov Bot commented Oct 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.88889% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 96.47%. Comparing base (62b98c0) to head (6bfc9dc).

Files with missing lines Patch % Lines
crates/fgumi-consensus/src/duplex_caller.rs 98.50% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1029      +/-   ##
==========================================
- Coverage   96.47%   96.47%   -0.01%     
==========================================
  Files         299      299              
  Lines      152214   152302      +88     
==========================================
+ Hits       146854   146932      +78     
- Misses       5360     5370      +10     

☔ 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

nh13 commented Oct 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 7, 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 added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 79be7f2 Oct 8, 2026
21 checks passed
@nh13
nh13 deleted the nh/ln-sum-exp-nan branch October 8, 2026 00:48
@nh13 nh13 mentioned this pull request Oct 8, 2026

This branch was successfully deployed

1 active deployment
github-actions — 6bfc9dc6 Deployed Oct 6, 2026 by nh13 via coverage #4845
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