Skip to content

fix(duplex): restore single-strand consensus via --min-reads 1,1,0 - #683

Merged
nh13 merged 1 commit into
mainfrom
nh/issue-678-single-strand-duplex
Aug 5, 2026
Merged

nh13 merged 1 commit into
mainfrom
nh/issue-678-single-strand-duplex

Conversation

@nh13

@nh13 nh13 commented Jul 31, 2026 •

Copy link
Copy Markdown
Member

Closes #678.

fgumi duplex --min-reads 1,1,0 — fgbio's single-strand mode, where a molecule observed on only one strand still yields a consensus — worked in 0.4.0 and errors out in 0.5.0. DuplexConsensusCaller has always implemented the mode; what changed is the CLI. #601 added a lower bound to Duplex::validate() for consistency with simplex and codec, but its blanket contains(&0) also rejected the per-strand slots, where a 0 is meaningful.

The bound is dropped entirely rather than narrowed, matching fgbio, which validates only that the values run from least to most stringent. simplex and codec keep their check, because there a 0 really does make the minimum-family-size filter a silent no-op — that part of #601 stands. duplex differs in both slots: a 0 per strand is the single-strand mode, and a total of 0 is equivalent to 1 rather than degenerate, because the check it feeds (min_total <= num_xy + num_yx) is only reached for a group that already has at least one read.

Restoring the flag alone was not enough to reproduce fgbio, because two code paths that are unreachable while the mode is gated were wrong.

The overlapping-bases consensus was skipped for single-strand groups. It is gated on the group having both strands, "no duplex possible anyway" — which stops being true once the third value is 0, so single-strand molecules with overlapping mates kept uncorrected bases. It is now gated on whether the group can actually produce a consensus.

BA-only molecules came out with R1/R2 swapped relative to fgbio. #298 mapped output R1 from BA-R2 and R2 from BA-R1, to keep output R1 on the physical strand that AB-R1 would have sequenced. That formula holds only while both strand groups are present; fgbio does not extend it to a lone group, taking whichever group is present as its "AB" side, so output R1 comes from that group's R1s whether it is /A or /B. Verified in the data — within a molecule, AB-R1 and BA-R1 are on opposite strands at opposite ends:

molecule 13395: A-R1=fwd@44192844   B-R1=rev@44193088
molecule 14413: A-R1=rev@67604810   B-R1=fwd@67604719

so the previous mapping put the opposite end of the molecule in R1 for /B-only molecules than for every other molecule class. Each consensus read is now built from the input reads of the same end. #298's other half is preserved: BA still goes in the BA slot, so is_ba_only stays true and per-strand methylation tags are still emitted as bm/bu/bt — that flag records which strand group the bases came from, which the end does not change. test_duplex_ba_only_methylation_tags_use_bottom_strand is untouched and passes; test_duplex_ba_only_mate_pair_mapping is updated to the new expectation.

Verification

On 20,000 simulated duplex molecules (81,368 raw reads), against fgbio 4.1.0, comparing with fgumi compare bams --command duplex:

--min-reads records content diffs vs fgbio
1 21,432 0
1,1,0 40,000 0
1,1,0 --threads 4 40,000 0
0 40,000 0

Before this change, 14,014 of those 40,000 records differed from fgbio, in two ways: R1/R2 swaps on /B-only molecules, and missing overlap corrections on single-strand molecules whose mates overlap. Disabling the overlapping consensus on both sides isolates the first defect, and it accounts for exactly 9,048 differing records — the R1/R2 pairs of all 4,524 /B-only molecules, with the 4,760 /A-only and 10,716 two-strand molecules matching fgbio exactly. --min-reads 1 is unchanged.

New tests, each watched failing first: --min-reads lower-bound cases; single-strand molecules are emitted with 1,1,0 and still rejected with 1; consensus reads follow input read ends for /A-only and /B-only molecules; the overlapping consensus is applied to single-strand groups. All are parameterized over single-threaded and --threads 2.

cargo ci-test (6,903 tests), cargo ci-fmt, and cargo ci-lint pass.

Note on fgbio's docs

fgbio implements this mode but does not document it — neither CallDuplexConsensusReads nor FilterConsensusReads mentions a 0 value, and DuplexConsensusCaller.duplexConsensus's own scaladoc ("If either of the incoming reads are undefined, the duplex read will be undefined") contradicts its case (Some(a), None) arm. This PR documents the mode on the fgumi side, in the duplex consensus guide and in --min-reads' help text.

Summary by CodeRabbit

  • New Features

    • Duplex consensus calling supports single-strand molecules with configurations such as --min-reads 1,1,0.
    • Single-strand consensus reads are marked with bD:i:0.
    • Consensus generation is consistent in threaded and single-threaded modes.
  • Bug Fixes

    • Corrected R1/R2 orientation and methylation handling for BA-only consensus output.
    • Improved overlapping-base handling and threshold enforcement.
    • Invalid --min-reads configurations are rejected before output is created.
  • Documentation

    • Added guidance on zero-valued thresholds, filtering, and fgbio equivalence.

@nh13 nh13 added bug Something isn't working fgumi duplex labels Jul 31, 2026
@coderabbitai

coderabbitai Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

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: 5283883e-a8d9-4d86-afed-d681fd2585eb

📥 Commits

Reviewing files that changed from the base of the PR and between 7fe0836 and fd78a27.

📒 Files selected for processing (4)
  • crates/fgumi-consensus/src/duplex_caller.rs
  • docs/src/guide/duplex-consensus-calling.md
  • src/lib/commands/duplex.rs
  • tests/integration/test_duplex_command.rs

Walkthrough

The duplex command accepts zero-valued thresholds, emits eligible single-strand consensus reads, and preprocesses overlapping mates in both execution modes. BA-only output preserves same-end R1/R2 mapping. Post-filter thresholds are enforced before emission.

Changes

Single-strand duplex consensus

Layer / File(s) Summary
Threshold validation and preprocessing
src/lib/commands/duplex.rs, crates/fgumi-consensus/src/duplex_caller.rs, docs/src/guide/duplex-consensus-calling.md
Zero thresholds are accepted. Shared helpers define validation, padding, and single-strand eligibility. Eligible groups receive overlapping-base preprocessing in threaded and single-threaded execution.
BA-only read-end mapping
crates/fgumi-consensus/src/duplex_caller.rs
BA-only output maps R1 from BA-R1 and R2 from BA-R2. BA methylation orientation and source-read mapping are covered by regression checks.
Single-strand emission and validation
crates/fgumi-consensus/src/duplex_caller.rs, tests/integration/test_duplex_command.rs
Consensus emission rechecks thresholds after alignment filtering. Tests cover /A and /B output, distinct read ends, orientation, provenance, overlap masking, depth tags, fgbio oracle behavior, and both execution modes.

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

Sequence Diagram(s)

sequenceDiagram
  participant DuplexCommand
  participant OverlappingConsensusPreprocessing
  participant DuplexConsensusCaller
  participant ConsensusOutput
  DuplexCommand->>DuplexConsensusCaller: validate zero-valued --min-reads
  DuplexCommand->>OverlappingConsensusPreprocessing: process eligible single-strand groups
  OverlappingConsensusPreprocessing->>DuplexConsensusCaller: provide filtered groups
  DuplexConsensusCaller->>DuplexConsensusCaller: recheck post-filter thresholds
  DuplexConsensusCaller->>ConsensusOutput: emit mapped R1/R2 consensus reads
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies restoring single-strand consensus through the --min-reads 1,1,0 configuration.
Linked Issues check ✅ Passed The changes implement issue #678 by accepting 1,1,0, emitting eligible single-strand consensus, and preserving required mapping and filtering behavior.
Out of Scope Changes check ✅ Passed The implementation, documentation, and tests remain within the linked issue's single-strand consensus scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nh/issue-678-single-strand-duplex

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

@nh13

nh13 commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai pause

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews paused.

@nh13
nh13 force-pushed the nh/issue-678-single-strand-duplex branch from 01b6083 to a748da5 Compare July 31, 2026 18:19
@nh13
nh13 temporarily deployed to github-actions July 31, 2026 18:19 — with GitHub Actions Inactive
@codecov

codecov Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.30201% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.95%. Comparing base (08458ba) to head (fd78a27).
⚠️ Report is 12 commits behind head on main.

Files with missing lines Patch % Lines
crates/fgumi-consensus/src/duplex_caller.rs 95.03% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #683      +/-   ##
==========================================
- Coverage   93.95%   93.95%   -0.01%     
==========================================
  Files         178      178              
  Lines      108059   108301     +242     
==========================================
+ Hits       101524   101750     +226     
- Misses       6535     6551      +16     

☔ 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 force-pushed the nh/issue-678-single-strand-duplex branch from a748da5 to a839a0d Compare July 31, 2026 18:25
@nh13
nh13 temporarily deployed to github-actions July 31, 2026 18:25 — with GitHub Actions Inactive
@nh13

nh13 commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 1, 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/commands/duplex.rs (1)

1203-1225: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test can no longer distinguish valid from invalid min_reads.
validate() (lines 587-602) dropped every min-reads check; ordering is enforced only in DuplexConsensusCaller::new. Every case in test_validate_min_reads_accepts_zero expects Ok, so the test now passes even if validate() ignored min_reads entirely — it cannot catch a regression that turns validate() into a no-op with respect to this field. Ordering violations are covered separately by duplex_caller.rs::test_min_reads_validation_error. Consider renaming or annotating this test to state explicitly that it only guards against reintroducing the removed lower-bound rejection.

Based on path instructions: "Flag assertions weaker than the stated contract."

🤖 Prompt for 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.

In `@src/lib/commands/duplex.rs` around lines 1203 - 1225, Rename or annotate
test_validate_min_reads_accepts_zero to explicitly indicate it only guards that
validate() does not reject zero-valued min_reads. Remove the expect_ok parameter
and redundant always-true assertions, or otherwise make the test clearly assert
the narrow lower-bound behavior; leave ordering validation to
DuplexConsensusCaller::new and its existing tests.

Source: Path instructions

🤖 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 `@src/lib/commands/duplex.rs`:
- Around line 604-611: Remove the duplicated padding check from
DuplexConsensusCaller’s allows_single_strand_consensus and reuse
DuplexConsensusCaller’s canonical min_yx_reads-based eligibility logic instead.
Expose a public instance method or static helper in DuplexConsensusCaller, then
update the CLI and threaded paths to call it so single-strand gating always
follows the caller’s padding semantics.

In `@tests/integration/test_duplex_command.rs`:
- Around line 438-484: Strengthen
test_duplex_single_strand_mode_emits_one_strand_molecules beyond
reader.records().count() by collecting the output records and validating both
consensus records’ identities: expected R1/R2 flags, matching molecule
identifier (MI), and non-empty sequences. Preserve the existing 2-record
expectation and the separate rejection assertion for requiring both strands.

---

Outside diff comments:
In `@src/lib/commands/duplex.rs`:
- Around line 1203-1225: Rename or annotate test_validate_min_reads_accepts_zero
to explicitly indicate it only guards that validate() does not reject
zero-valued min_reads. Remove the expect_ok parameter and redundant always-true
assertions, or otherwise make the test clearly assert the narrow lower-bound
behavior; leave ordering validation to DuplexConsensusCaller::new and its
existing tests.
🪄 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: 50def66f-fc4b-46eb-a049-a58bbaf39b2a

📥 Commits

Reviewing files that changed from the base of the PR and between 08458ba and a839a0d.

📒 Files selected for processing (4)
  • crates/fgumi-consensus/src/duplex_caller.rs
  • docs/src/guide/duplex-consensus-calling.md
  • src/lib/commands/duplex.rs
  • tests/integration/test_duplex_command.rs

Comment thread src/lib/commands/duplex.rs
Comment thread tests/integration/test_duplex_command.rs
@nh13
nh13 force-pushed the nh/issue-678-single-strand-duplex branch from a839a0d to 7d695ac Compare August 1, 2026 16:14
@nh13
nh13 temporarily deployed to github-actions August 1, 2026 16:14 — with GitHub Actions Inactive
@nh13

nh13 commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Addressed the outside-diff comment on src/lib/commands/duplex.rs:1203-1225 (no inline thread to resolve): test_validate_min_reads_accepts_zero is renamed to test_validate_does_not_reject_zero_min_reads, the always-true expect_ok parameter is dropped, and the doc comment now states explicitly that the test guards only against reintroducing the removed lower-bound rejection — ordering validation stays in DuplexConsensusCaller::new, covered by test_min_reads_validation_error.

Two further fixes from a self-review pass:

  • The --min-reads doc text I added said the values must be given "from least to most stringent", which is backwards and contradicted this commands own long help ("if values two and three differ, the more stringent value comes earlier", matching fgbio CallDuplexConsensusReads.scala:101`). Corrected at all three sites.
  • Added a unit test for the new ConsensusCallingStats::record_consensus_pair, which counts two emitted reads.

@nh13
nh13 force-pushed the nh/issue-678-single-strand-duplex branch from 7d695ac to d0475cb Compare August 1, 2026 17:01
@nh13
nh13 temporarily deployed to github-actions August 1, 2026 17:01 — with GitHub Actions Inactive
@nh13

nh13 commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 2, 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/fgumi-consensus/src/duplex_caller.rs (1)

2169-2276: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Single-strand arms skip the post-filtering minimum-reads check.

The both-strand arm (Lines 2110-2122) rebuilds dr1/dr2 and then calls duplex_consensus_has_minimum_reads before emitting, because filter_by_alignment (CIGAR filtering) can drop reads after has_minimum_number_of_reads already passed on the raw pre-filter counts. The AB-only arm (Lines 2169-2216) and the BA-only arm (Lines 2217-2276) skip this recheck entirely: as soon as min_yx_reads == 0 and both duplex_r1/duplex_r2 build, the read is emitted regardless of min_total_reads or min_xy_reads.

Before this PR, min_yx_reads == 0 was unreachable because Duplex::validate() rejected a zero YX value, so this gap was dormant. This PR removes that CLI-level rejection, so the gap is now reachable: with, for example, --min-reads 5,3,0, a single-strand group whose alignment-filtered read count drops below 5 (or 3) still emits a consensus, silently violating the configured threshold.

Mirror the both-strand arm's check in both single-strand arms.

🐛 Proposed fix for both single-strand arms
             (Some(ref r1_a), Some(ref r2_a), None, None) => {
                 // Only AB strand - check if single-strand consensus is allowed (min_yx_reads == 0)
                 if min_yx_reads == 0 {
                     // Use duplex_consensus with only AB (no BA)
                     let duplex_r1 = Self::duplex_consensus(Some(r1_a), None, None);
                     let duplex_r2 = Self::duplex_consensus(Some(r2_a), None, None);

-                    if let (Some(dr1), Some(dr2)) = (duplex_r1, duplex_r2) {
+                    if let (Some(dr1), Some(dr2)) = (duplex_r1, duplex_r2)
+                        && Self::duplex_consensus_has_minimum_reads(&dr1, min_total_reads, min_xy_reads, min_yx_reads)
+                        && Self::duplex_consensus_has_minimum_reads(&dr2, min_total_reads, min_xy_reads, min_yx_reads)
+                    {
                         let empty: &[&RawRecord] = &[];
                         ...
                     }
                 }
             }
             (None, None, Some(ref r1_b), Some(ref r2_b)) => {
                 ...
                 if min_yx_reads == 0 {
                     let duplex_r1 = Self::duplex_consensus(None, Some(r1_b), None);
                     let duplex_r2 = Self::duplex_consensus(None, Some(r2_b), None);

-                    if let (Some(dr1), Some(dr2)) = (duplex_r1, duplex_r2) {
+                    if let (Some(dr1), Some(dr2)) = (duplex_r1, duplex_r2)
+                        && Self::duplex_consensus_has_minimum_reads(&dr1, min_total_reads, min_xy_reads, min_yx_reads)
+                        && Self::duplex_consensus_has_minimum_reads(&dr2, min_total_reads, min_xy_reads, min_yx_reads)
+                    {
                         let empty: &[&RawRecord] = &[];
                         ...
                     }
                 }
             }

Add a test with a non-trivial threshold (e.g. --min-reads 5,3,0) plus a dissimilar-CIGAR read that trims the surviving single-strand count below the threshold, and assert rejection.

As per path instructions: "Flag silent behavior changes on the edge cases that differ between tools: ... min/max family-size thresholds."

🤖 Prompt for 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.

In `@crates/fgumi-consensus/src/duplex_caller.rs` around lines 2169 - 2276, Mirror
the both-strand arm’s post-filter validation in the AB-only and BA-only branches
of the consensus-building function: after constructing dr1 and dr2, require
duplex_consensus_has_minimum_reads to pass using the filtered strand counts and
configured min_total_reads/min_xy_reads/min_yx_reads before emitting or
recording the pair. Add a regression test using a threshold such as 5,3,0 and a
dissimilar-CIGAR read that lowers the surviving single-strand count below the
threshold, asserting no consensus is emitted.

Source: Path instructions

🤖 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 `@docs/src/guide/duplex-consensus-calling.md`:
- Around line 87-112: In the “Single-strand molecules” section, update the
sentence to use “whether it makes duplex” and change “afterwards” to
“afterward,” preserving all other wording and behavior descriptions.

---

Outside diff comments:
In `@crates/fgumi-consensus/src/duplex_caller.rs`:
- Around line 2169-2276: Mirror the both-strand arm’s post-filter validation in
the AB-only and BA-only branches of the consensus-building function: after
constructing dr1 and dr2, require duplex_consensus_has_minimum_reads to pass
using the filtered strand counts and configured
min_total_reads/min_xy_reads/min_yx_reads before emitting or recording the pair.
Add a regression test using a threshold such as 5,3,0 and a dissimilar-CIGAR
read that lowers the surviving single-strand count below the threshold,
asserting no consensus is emitted.
🪄 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: c6f6f50c-7d20-4369-b88b-c0abfb8a762d

📥 Commits

Reviewing files that changed from the base of the PR and between a839a0d and d0475cb.

📒 Files selected for processing (4)
  • crates/fgumi-consensus/src/duplex_caller.rs
  • docs/src/guide/duplex-consensus-calling.md
  • src/lib/commands/duplex.rs
  • tests/integration/test_duplex_command.rs

Comment thread docs/src/guide/duplex-consensus-calling.md
@nh13
nh13 force-pushed the nh/issue-678-single-strand-duplex branch from d0475cb to d149794 Compare August 2, 2026 15:49
@nh13
nh13 temporarily deployed to github-actions August 2, 2026 15:49 — with GitHub Actions Inactive
@nh13

nh13 commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Addressed the outside-diff finding on crates/fgumi-consensus/src/duplex_caller.rs:2169-2276 (no inline thread to resolve).

The AB-only and BA-only arms now mirror the both-strand arm and re-check duplex_consensus_has_minimum_reads against the post-filter_by_alignment depths before emitting. This restores fgbio parity: fgbio applies duplexHasMinimumNumberOfReads to every emitted consensus regardless of strand count (DuplexConsensusCaller.scala:221), with its own comment naming exactly this case — "Even though we had enough reads going into consensus calling, we can lose some in the process (e.g. to CIGAR filtering)". Failure falls through to the existing rejection at the end of the function, so the InsufficientReads accounting and reject-BAM passthrough are unchanged.

Regression test test_single_strand_rechecks_min_reads_after_alignment_filtering covers both arms with the suggested threshold: five single-strand pairs clear 5,3,0 on the raw counts, but two carry 5M1I4M (neither a prefix of nor prefixed by 10M), so the most-common-alignment group keeps three and the molecule must be rejected. Verified it fails on the pre-fix code (emitted 2 records). The 3,3,0 control cases assert record identity rather than a count — R1/R2 flags, shared read name, base MI, 10bp sequence, aD:i:3 (pinning that only the filtered reads contributed) and bD:i:0.

Also applied the two wording nits in docs/src/guide/duplex-consensus-calling.md: "afterwards" → "afterward", and "whether or not it makes duplex" → "regardless of whether it makes duplex" (the intended sense is the regardless-of one, which is what LanguageTool's WHETHER rule asks be made explicit).

@nh13

nh13 commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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 `@src/lib/commands/duplex.rs`:
- Around line 593-612: Update validate() to enforce the --min-reads ordering
constraint (total >= XY >= YX) before execution begins, matching
DuplexConsensusCaller::new. Preserve support for unbounded nonnegative values
and valid single-strand configurations, while ensuring execute_threads_mode
rejects invalid input before creating output or starting workers.
🪄 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: a7da703f-759e-49e8-9ae0-bc75d1773114

📥 Commits

Reviewing files that changed from the base of the PR and between d0475cb and d149794.

📒 Files selected for processing (4)
  • crates/fgumi-consensus/src/duplex_caller.rs
  • docs/src/guide/duplex-consensus-calling.md
  • src/lib/commands/duplex.rs
  • tests/integration/test_duplex_command.rs

Comment thread src/lib/commands/duplex.rs Outdated
@nh13
nh13 force-pushed the nh/issue-678-single-strand-duplex branch from d149794 to ec29fcd Compare August 3, 2026 15:05
@nh13

nh13 commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 4, 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `@docs/src/guide/duplex-consensus-calling.md`:
- Around line 83-86: Update the fgumi `--min-reads` example in the duplex
consensus calling documentation from space-separated values to comma-separated
`7,3,1`, while preserving the space-separated `7 3 1` form only in the
explicitly labeled fgbio command example.

In `@src/lib/commands/duplex.rs`:
- Around line 501-504: In both overlapping-consensus gates in
src/lib/commands/duplex.rs at lines 501-504 and 754-758, reorder the condition
so single_strand_allowed is evaluated before the expensive has_both_strands_raw
scan; update both sites to use short-circuit ordering while preserving the
existing consensus behavior.

In `@tests/integration/test_duplex_command.rs`:
- Around line 692-701: Update create_fully_overlapping_pair to assert that
r1_sequence and r2_sequence have equal lengths before deriving read_len or
constructing either record, matching the validation behavior of
create_duplex_read_pair_with_sequences.
🪄 Autofix

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: b1f8a47e-3f58-4485-9289-90bf24bb35bd

📥 Commits

Reviewing files that changed from the base of the PR and between d149794 and ec29fcd.

📒 Files selected for processing (4)
  • crates/fgumi-consensus/src/duplex_caller.rs
  • docs/src/guide/duplex-consensus-calling.md
  • src/lib/commands/duplex.rs
  • tests/integration/test_duplex_command.rs

Comment thread docs/src/guide/duplex-consensus-calling.md Outdated
Comment thread src/lib/commands/duplex.rs Outdated
Comment thread tests/integration/test_duplex_command.rs
@nh13
nh13 force-pushed the nh/issue-678-single-strand-duplex branch from ec29fcd to f9d610f Compare August 4, 2026 15:40
@nh13
nh13 temporarily deployed to github-actions August 4, 2026 15:40 — with GitHub Actions Inactive
@nh13

nh13 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 5, 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.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/fgumi-consensus/src/duplex_caller.rs (1)

6576-6826: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Pin single-strand output to an independent fgbio oracle.

The current tests derive expectations from fgumi inputs or compare two fgumi modes. They can pass when BA read-end mapping, post-filter threshold handling, or overlap masking differs from fgbio.

  • crates/fgumi-consensus/src/duplex_caller.rs#L6576-L6826: Add programmatically generated AB-only and BA-only 1,1,0 fixtures with complete captured fgbio 4.1.0 R1/R2 output identity, including flags, bases, MI/RX, depth/error tags, and applicable methylation tags.
  • tests/integration/test_duplex_command.rs#L725-L786: Assert complete command output identity against the captured overlap-correction oracle in both execution modes.

Keep fixture regeneration ignored. Do not require fgbio or a JVM in default CI.

Based on learnings, fgbio-backed tests must use fixed expected outputs and gated regeneration. As per path instructions, “New correctness-critical behavior needs an INDEPENDENT oracle … not just a self-consistency check.”

🤖 Prompt for 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.

In `@crates/fgumi-consensus/src/duplex_caller.rs` around lines 6576 - 6826, Add
programmatically generated AB-only and BA-only 1,1,0 fixtures in
DuplexConsensusCaller tests, using captured fgbio 4.1.0 outputs as fixed
independent expectations for complete R1/R2 identity: flags, bases, MI/RX,
depth/error tags, and applicable methylation tags; keep regeneration gated and
ignored so default CI needs neither fgbio nor a JVM. Also update
tests/integration/test_duplex_command.rs lines 725-786 to assert complete
command-output identity against the captured overlap-correction oracle in both
execution modes, while preserving the existing fixture-based execution path.

Sources: Path instructions, Learnings

🤖 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 `@tests/integration/test_duplex_command.rs`:
- Around line 436-534: Extend
test_duplex_single_strand_mode_emits_one_strand_molecules to run the same
one-template input with --min-reads 2,2,0 for both thread configurations and
assert that no consensus records are emitted. Ensure the assertion verifies the
paired-end template counts as one template rather than two individual read
records, while preserving the existing successful 1,1,0 and rejected 1 cases.

---

Outside diff comments:
In `@crates/fgumi-consensus/src/duplex_caller.rs`:
- Around line 6576-6826: Add programmatically generated AB-only and BA-only
1,1,0 fixtures in DuplexConsensusCaller tests, using captured fgbio 4.1.0
outputs as fixed independent expectations for complete R1/R2 identity: flags,
bases, MI/RX, depth/error tags, and applicable methylation tags; keep
regeneration gated and ignored so default CI needs neither fgbio nor a JVM. Also
update tests/integration/test_duplex_command.rs lines 725-786 to assert complete
command-output identity against the captured overlap-correction oracle in both
execution modes, while preserving the existing fixture-based execution path.
🪄 Autofix

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: bbe0689d-15c9-4ef7-9207-5b4524717014

📥 Commits

Reviewing files that changed from the base of the PR and between 5d38ae0 and f9d610f.

📒 Files selected for processing (4)
  • crates/fgumi-consensus/src/duplex_caller.rs
  • docs/src/guide/duplex-consensus-calling.md
  • src/lib/commands/duplex.rs
  • tests/integration/test_duplex_command.rs

Comment thread tests/integration/test_duplex_command.rs
@nh13
nh13 force-pushed the nh/issue-678-single-strand-duplex branch from f9d610f to de7b817 Compare August 5, 2026 06:53
@nh13
nh13 temporarily deployed to github-actions August 5, 2026 06:53 — with GitHub Actions Inactive
@nh13

nh13 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Addressed both findings from the latest review.

Template-count boundary (inline thread, resolved). The literal suggestion — --min-reads 2,2,0 on "the same one-template input" — misreads the fixture: create_single_strand_molecule(..., depth = 3, ...) builds three pairs, not one, so 2,2,0 clears the threshold and emits. The underlying concern is real though, so test_duplex_single_strand_mode_emits_one_strand_molecules now straddles the template count instead: 3,3,0 (equal to the 3 templates) must still emit, and 4,4,0 must not. That pair is what discriminates, since the molecule is 3 templates but 6 read records — an implementation counting records would emit in both cases. Verified by mutation: inflating the depth in duplex_consensus_has_minimum_reads makes the 4,4,0 case fail. (Inflating only has_minimum_number_of_reads does not, because the post-filter re-check added earlier in this PR catches it — the end-to-end contract is pinned either way.)

Both accepting cases now assert full pair identity (two records, exactly one R1, molecule id, bases) through a shared assert_is_the_molecules_pair closure, rather than a bare count.

fgbio oracle for single-strand output (outside-diff, no thread to resolve). Added duplex_single_strand_matches_fgbio_oracle in duplex_caller.rs's fgbio_oracle_tests, with /A-only and /B-only fixtures built by build_single_strand_fixture and regenerated by the #[ignore]d regen_duplex_single_strand_{ab,ba}_only_fixture tests. Expectations are captured from a real fgbio 4.1.0 CallDuplexConsensusReads -M 1 1 0 run on those exact fixture BAMs — no fgbio or JVM in default CI. R1 and R2 carry different bases (ACGTACGT / TTGGCCAA, neither the reverse complement of the other) so the read-end mapping is observable; one pair's R1 disagrees at base 0 so the error numerator is exercised.

Pinned per record: flags, bases, qualities, MI, RX, aD/aM/aE, bD/bM/bE, cD/cM/cE, ad, ae, ac, aq — plus the tag shape, i.e. that bd/be/bc/bq are absent, which is what fgbio does for a lone strand and which value-only assertions would miss. Methylation tags are not pinned: fgbio emits them only for a bisulfite run, which this fixture is not; the /B-only orientation stays covered by test_duplex_ba_only_uses_bottom_strand_methylation_tags. Verified the oracle catches the pre-fix BA mapping — restoring BA-R2 → R1 fails the ba_only case on R1 bases and leaves ab_only passing.

The /A and /B cases pin the same bases in the same slots; only the error position differs (base 0 vs base 7), because the /B fixture's R1s are REVERSE and fgbio consensus-calls in sequencing orientation.

Overlap-correction oracle. test_duplex_single_strand_mode_applies_overlapping_consensus no longer asserts a "contains an N" predicate. write_overlapping_single_strand_fixture now serves both the test and a new #[ignore]d regen_duplex_single_strand_overlap_fixture, and both the corrected and uncorrected outputs are pinned to the captured fgbio run in both execution modes:

  • --consensus-call-overlapping-bases true → R1 ACGTACG / HHHHHHH, R2 NCGTACGT / #HHHHHHH
  • --consensus-call-overlapping-bases false → R1 ACGTACGT / >>>>>>>>, R2 TCGTACGT / >>>>>>>>

fgumi's output is byte-identical to fgbio's on both settings. The asymmetry is fgbio's: the mates disagree at the last reference base, which masks to N at qual 2 in both, and in sequencing orientation that is the end of R1 (trimmed) and the start of R2 (kept as N at depth 0). Confirmed by running the same command on a fixture that disagrees at the first reference base instead, which swaps the two records' shapes exactly.

Three fixes from a self-review pass over the diff:

  • The OVERLAP_R2_BASES doc comment claimed the mates disagree "at reference offset 0" and described TCGTACGT as R2's reference bases. BAM SEQ is stored reference-forward, so R2's reference bases are ACGTACGA and the disagreement is at offset 7 — contradicting the comment 100 lines below it in the same test. Corrected; TCGTACGT is now named as the sequencing orientation.
  • validate_min_reads had an unreachable .unwrap_or(last) on min_yx_reads_for, whose only None case the let-else above had already rejected. Folded into the same let-else so emptiness is checked once.
  • The new integration regen test panicked when FGUMI_ORACLE_BAM_OUT was unset, where the sibling regen_write helper falls back to a logged temp path. Matched the ergonomics (that helper is pub(crate) to fgumi-consensus, so it cannot be shared).

@nh13

nh13 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 5, 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.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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/duplex_caller.rs`:
- Around line 7368-7378: Update the tag validation loop in the duplex record
assertion to use get_i16_array_tag for SamTag::BD_BASES and SamTag::BE_BASES,
while retaining get_string_tag checks for SamTag::BC_BASES and SamTag::BQ.
Ensure the absence assertion detects the emitted B:s arrays for bd and be
without changing the existing error behavior.
🪄 Autofix

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: f364ff97-8a94-472d-a0b9-bc0eac8abff4

📥 Commits

Reviewing files that changed from the base of the PR and between 7fe0836 and de7b817.

📒 Files selected for processing (4)
  • crates/fgumi-consensus/src/duplex_caller.rs
  • docs/src/guide/duplex-consensus-calling.md
  • src/lib/commands/duplex.rs
  • tests/integration/test_duplex_command.rs

Comment thread crates/fgumi-consensus/src/duplex_caller.rs Outdated
0.4.0 accepted a third `--min-reads` value of 0 -- fgbio's single-strand
mode, where a molecule observed on only one strand still yields a
consensus -- and `DuplexConsensusCaller` has always implemented it. #601
then added a lower bound to `Duplex::validate()` for consistency with
simplex and codec, but its blanket `contains(&0)` also rejected the
per-strand slots, taking the mode away in 0.5.0.

Drop the bound entirely rather than narrowing it, matching fgbio, which
validates only that the values run from least to most stringent. For
simplex and codec a 0 really does make the minimum-family-size filter a
silent no-op, which is what #601 was about; duplex is different in both
slots. A 0 per strand is the single-strand mode, and a total of 0 is
equivalent to 1 rather than degenerate, because the check it feeds
(`min_total <= num_xy + num_yx`) is only reached for a group that already
has at least one read.

Restoring the flag alone was not enough to reproduce fgbio, because two
paths that are unreachable while the mode is gated were wrong:

The overlapping-bases consensus was skipped for groups without both
strands, on the grounds that no duplex consensus can be built from them.
That stops being true when the third value is 0, so single-strand
molecules with overlapping mates kept uncorrected bases. Gate on whether
the group can actually produce a consensus instead.

The BA-only arm mapped output R1 from BA-R2 and R2 from BA-R1 (#298), to
keep output R1 on the physical strand AB-R1 would have sequenced. That
formula holds only while both strand groups are present: fgbio takes a
lone group as its "AB" side, so output R1 comes from that group's R1s
whether it is /A or /B. The previous mapping put the opposite end of the
molecule in R1 for /B-only molecules than for every other molecule class.
Build each consensus read from the input reads of the same end. #298's
other half is preserved -- BA still goes in the BA slot, so is_ba_only
stays true and per-strand methylation tags remain bm/bu/bt.

On 20,000 simulated duplex molecules, output is now identical to fgbio
4.1.0 for `--min-reads 1,1,0` (40,000 records) and `--min-reads 0`
(40,000 records), single- and multi-threaded; `--min-reads 1` is unchanged
and still identical to fgbio.

Closes #678
@nh13
nh13 force-pushed the nh/issue-678-single-strand-duplex branch from de7b817 to fd78a27 Compare August 5, 2026 07:35
@nh13
nh13 temporarily deployed to github-actions August 5, 2026 07:35 — with GitHub Actions Inactive
@nh13

nh13 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Good catch — the finding is correct and the assertion was genuinely vacuous.

get_u8_array_tag resolves B:C, but bd/be are written as B:s (append_i16_array_tag, duplex_caller.rs:1247 and :1251), so neither branch of get_u8_array_tag(tag).is_none() && get_string_tag(tag).is_none() could ever see them. Confirmed by mutation: emitting zero-filled bd/be for the absent strand left both oracle cases passing. With the accessors split by written type — get_i16_array_tag for bd/be, get_string_tag for bc (append_string_tag) and bq (append_phred33_string_tag, which emits Z) — the same mutation fails on R1 must NOT carry bd. Reverted the mutation; both cases pass on the real code.

Swept the class rather than fixing only the cited site. Every tag-absence assertion in the changed files, checked against how the tag is actually written:

Site Written as Accessor Verdict
duplex_caller.rs:7375 — bd/be append_i16_array_tag → B:s get_i16_array_tag fixed
duplex_caller.rs:7381 — bc/bq append_string_tag / append_phred33_string_tag → Z get_string_tag already correct
duplex_caller.rs:6567,6571 — au/at append_i16_array_tag → B:s get_i16_array_tag already correct
duplex_caller.rs:5127 — CB append_string_tag → Z get_string_tag already correct

No other site had the mismatch. The presence assertions in the same test (ad, ae, ac, aq) cannot go vacuous the same way — a wrong accessor returns None and fails immediately.

Added a comment at the site recording why the accessor has to match the written type, so the next edit does not reintroduce it.

@nh13

nh13 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

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 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 5, 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.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@nh13
nh13 merged commit 5dd644b into main Aug 5, 2026
15 checks passed
@nh13
nh13 deleted the nh/issue-678-single-strand-duplex branch August 5, 2026 15:50
@nh13 nh13 mentioned this pull request Aug 5, 2026
@nh13 nh13 mentioned this pull request Aug 15, 2026

This branch was previously deployed

1 inactive deployment
github-actions — fd78a279 Deployed Aug 5, 2026 by nh13 via coverage #3278
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working fgumi duplex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fgumi duplex to also allow single strand consensus

1 participant