Skip to content

fix(compare): preserve /A /B strand suffix in paired-UMI MI keys - #308

Merged
nh13 merged 1 commit into
mainfrom
nh/paired-umi-mi-strand-suffix
Apr 22, 2026
Merged

nh13 merged 1 commit into
mainfrom
nh/paired-umi-mi-strand-suffix

Conversation

@nh13

@nh13 nh13 commented Apr 22, 2026

Copy link
Copy Markdown
Member

Summary

fgumi compare bams --mode grouping incorrectly reported paired-UMI BAMs as disagreeing (Records matched: 0, BAM groupings DIFFER) even when the two files had identical groupings. The MI reader called str::parse::<i64>() on the tag value, which rejects the spec-compliant paired-strand encoding MI:Z:<id>/<A|B> emitted by both fgumi and fgbio, so every record was counted as "missing MI".

This PR replaces the i64-valued MI map with a small MiKey enum that parses all three observed MI forms and preserves the /A vs /B distinction for grouping-equivalence checks:

  • MI:i:<int> → MiKey::Int
  • MI:Z:<int> → MiKey::Int
  • MI:Z:<int>/A / MI:Z:<int>/B → MiKey::Strand { base, strand }
  • anything else → None (treated as missing, as before)

Display round-trips each form back to the original BAM encoding so the existing grouping-mismatch error messages (MI group '…' in BAM1 (N reads) maps to multiple MIs in BAM2) read unchanged.

Why not pack into i64?

MoleculeId::compact_index() (base*2, base*2+1) would collide with Single(n) if a file mixed single and paired MIs, silently aliasing groups and masking real disagreements. The enum is the minimal type that preserves the A/B invariant without that risk.

Test plan

New tests (all in src/lib/commands/compare/bams.rs and tests/integration/test_compare_bams.rs):

  • Unit: parse MI:i:42 → Int(42)
  • Unit: parse MI:Z:42 → Int(42)
  • Unit: parse MI:Z:0/A → Strand { base: 0, strand: b'A' }
  • Unit: parse MI:Z:7/B → Strand { base: 7, strand: b'B' }
  • Unit: 13/A and 13/B produce different keys (core invariant)
  • Unit: missing MI / non-numeric / unknown-strand all return None
  • Unit: Display round-trips each form
  • Integration: ordered grouping mode on identical paired-strand BAMs reports EQUIVALENT with zero missing MI
  • Integration: --ignore-order grouping mode on identical paired-strand BAMs reports EQUIVALENT with zero missing MI
  • Integration: swapping /A ↔ /B between the two BAMs is detected as a grouping mismatch
  • Verified RED: with the fix stashed, both positive integration tests fail with the exact symptom from the bug report (Missing MI in BAM1: 4, Records matched: 0, DIFFER)
  • cargo ci-fmt, cargo ci-lint, cargo ci-test (2494 passed)

@nh13
nh13 temporarily deployed to github-actions April 22, 2026 03:22 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Apr 22, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@nh13 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 48 minutes and 30 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 48 minutes and 30 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3caed066-3144-484e-b6d1-95685a576100

📥 Commits

Reviewing files that changed from the base of the PR and between f4ddcbf and 84527b9.

📒 Files selected for processing (2)
  • src/lib/commands/compare/bams.rs
  • tests/integration/test_compare_bams.rs
📝 Walkthrough

Walkthrough

MI value handling in BAM comparison was refactored to support paired-UMI notation. The representation changed from a simple integer (i64) to an enum (MiKey) accommodating both standalone integers and strand-annotated pairs (/A, /B suffixes). MI tag extraction now parses both typed (MI:i:<int>) and string (MI:Z:<int>) formats, plus paired variants. The new type propagated throughout grouping construction, grouping comparison, and consistency checks. Unit tests validate parsing edge cases; integration tests verify paired-UMI equivalence and mismatch scenarios.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the core fix: preserving strand suffixes (/A /B) in paired-UMI MI keys, which matches the primary change.
Description check ✅ Passed The description thoroughly explains the bug, the solution, and test coverage, all directly related to the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nh/paired-umi-mi-strand-suffix

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@codecov

codecov Bot commented Apr 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.91837% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.58%. Comparing base (c893602) to head (84527b9).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/lib/commands/compare/bams.rs 95.91% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #308      +/-   ##
==========================================
+ Coverage   90.46%   90.58%   +0.12%     
==========================================
  Files         101      101              
  Lines       60094    60160      +66     
==========================================
+ Hits        54366    54498     +132     
+ Misses       5728     5662      -66     

☔ View full report in Codecov by Sentry.
📢 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 Apr 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

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.

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/compare/bams.rs (1)

1467-1473: ⚠️ Potential issue | 🟡 Minor

Use Display for sampled MiKey diagnostics.

These mismatch messages still use Debug, so paired MI values render as enum internals like Strand { base: 3, strand: 65 } instead of 3/A.

Proposed fix
+fn format_mi_key_sample<'a>(values: impl IntoIterator<Item = &'a MiKey>) -> String {
+    format!("[{}]", values.into_iter().map(ToString::to_string).join(", "))
+}
+
                         "MI group '{}' in BAM1 ({} reads) maps to {} different MIs in BAM2: {:?}",
                         mi1,
                         read_hashes.len(),
                         mi2_values.len(),
-                        mi2_values.iter().take(5).collect::<Vec<_>>()
+                        format_mi_key_sample(mi2_values.iter().take(5))
                     ));

Apply the same replacement to the BAM2→BAM1 messages.

Also applies to: 1486-1492, 1778-1784, 1797-1803

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/commands/compare/bams.rs` around lines 1467 - 1473, The diagnostic
strings are using Debug for MiKey values so enums print internals; update the
sampled-value construction in the grouping_errors.push calls (the one that
references mi1, read_hashes, mi2_values) to render MiKey with Display instead of
Debug by mapping the sampled iterator to strings (e.g.,
mi2_values.iter().take(5).map(|m| m.to_string()).collect::<Vec<_>>()) and join
or collect those strings into the message; apply the same change to the
symmetric BAM2→BAM1 grouping_errors.push sites and the other occurrences that
reference mi1/mi2, mi1_values/mi2_values, and MiKey so all sampled MiKey
diagnostics use Display.
🧹 Nitpick comments (1)
tests/integration/test_compare_bams.rs (1)

654-657: Assert the mismatch reason, not just failure.

This test would also pass if paired MI strings regressed to “missing MI”. Check zero missing MI plus a grouping mismatch so it specifically covers /A vs /B preservation.

Proposed test hardening
     let (success, stdout) = run_compare(&bam1, &bam2, "grouping", &["--ignore-order"]);
     assert!(!success, "Expected DIFFER when /A and /B assignments disagree, stdout:\n{stdout}");
     assert!(stdout.contains("DIFFER"), "Expected DIFFER, got:\n{stdout}");
+    assert!(
+        stdout.contains("Missing MI in BAM1: 0") && stdout.contains("Missing MI in BAM2: 0"),
+        "Expected paired-strand MIs to be parsed as present, got:\n{stdout}"
+    );
+    assert!(
+        stdout.contains("Grouping mismatches: 1"),
+        "Expected the failure to be a grouping mismatch, got:\n{stdout}"
+    );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/test_compare_bams.rs` around lines 654 - 657, The test
currently only checks that run_compare returned a failure and that stdout
contains "DIFFER"; strengthen it to assert the specific mismatch reason by
checking stdout contains both the zero-missing-MI indicator and the grouping
mismatch text so it fails if MI handling regresses. Update the assertions around
run_compare(&bam1, &bam2, "grouping", &["--ignore-order"]) to assert stdout
contains "0 missing MI" (or the exact zero-missing wording your tool emits) and
also contains the grouping/assignment mismatch phrase (e.g. "grouping" or "/A vs
/B" as emitted), in addition to keeping the existing DIFFER check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/lib/commands/compare/bams.rs`:
- Around line 1467-1473: The diagnostic strings are using Debug for MiKey values
so enums print internals; update the sampled-value construction in the
grouping_errors.push calls (the one that references mi1, read_hashes,
mi2_values) to render MiKey with Display instead of Debug by mapping the sampled
iterator to strings (e.g., mi2_values.iter().take(5).map(|m|
m.to_string()).collect::<Vec<_>>()) and join or collect those strings into the
message; apply the same change to the symmetric BAM2→BAM1 grouping_errors.push
sites and the other occurrences that reference mi1/mi2, mi1_values/mi2_values,
and MiKey so all sampled MiKey diagnostics use Display.

---

Nitpick comments:
In `@tests/integration/test_compare_bams.rs`:
- Around line 654-657: The test currently only checks that run_compare returned
a failure and that stdout contains "DIFFER"; strengthen it to assert the
specific mismatch reason by checking stdout contains both the zero-missing-MI
indicator and the grouping mismatch text so it fails if MI handling regresses.
Update the assertions around run_compare(&bam1, &bam2, "grouping",
&["--ignore-order"]) to assert stdout contains "0 missing MI" (or the exact
zero-missing wording your tool emits) and also contains the grouping/assignment
mismatch phrase (e.g. "grouping" or "/A vs /B" as emitted), in addition to
keeping the existing DIFFER check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 65589270-a768-470c-b5f4-3e1283df53f5

📥 Commits

Reviewing files that changed from the base of the PR and between c893602 and f4ddcbf.

📒 Files selected for processing (2)
  • src/lib/commands/compare/bams.rs
  • tests/integration/test_compare_bams.rs

@nh13 nh13 added bug Something isn't working fgumi compare labels Apr 22, 2026
Paired-UMI grouping (fgumi and fgbio) writes MI as a Z-typed string of
the form "<id>/<A|B>". The grouping-mode comparator parsed MI via
`str::parse::<i64>()`, which rejects the suffixed form, so every record
in a paired-UMI BAM was counted as "missing MI" and the comparator
reported `Records matched: 0 / BAM groupings DIFFER` on files that
agreed exactly.

Replace the `i64`-valued MI map with a `MiKey` enum that preserves the
strand byte (`b'A'` / `b'B'`) alongside the molecule id, so the /A vs /B
distinction is retained for grouping equivalence checks. A bare integer
MI (string or int-typed) round-trips through `MiKey::Int`; a strand
suffix round-trips through `MiKey::Strand { base, strand }`; Display
reproduces the original BAM encoding so the existing error messages
("MI group '...' in BAM1 ...") read unchanged.

Covered by new unit tests for the parser (integer, string-integer,
`/A`, `/B`, invalid-strand, non-numeric) and three integration tests
that build paired-UMI BAMs and exercise both the ordered and
`--ignore-order` grouping paths plus the /A↔/B flip case.

This branch was previously deployed

1 inactive deployment
github-actions — 84527b97 Deployed Apr 22, 2026 by nh13 via coverage #1295
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 compare

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant