Skip to content

feat: allow variable length UMIs - #1148

Open
TimD1 wants to merge 5 commits into
mainfrom
557_td_allow-varable-length-umis
Open

feat: allow variable length UMIs#1148
TimD1 wants to merge 5 commits into
mainfrom
557_td_allow-varable-length-umis

Conversation

@TimD1

@TimD1 TimD1 commented Mar 24, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR builds off of a previous effort to allow variable-length UMIs.

Related Issues/PRs

Comment thread src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala
@TimD1
TimD1 force-pushed the 557_td_allow-varable-length-umis branch from 01b9d61 to cc4ee24 Compare March 24, 2026 20:53
@codecov

codecov Bot commented Mar 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.87500% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 96.02%. Comparing base (9634745) to head (2c988b2).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
...in/scala/com/fulcrumgenomics/umi/CorrectUmis.scala 92.30% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1148      +/-   ##
==========================================
+ Coverage   95.97%   96.02%   +0.05%     
==========================================
  Files         132      132              
  Lines        8040     8168     +128     
  Branches      562      612      +50     
==========================================
+ Hits         7716     7843     +127     
- Misses        324      325       +1     
Flag Coverage Δ
unittests 96.02% <96.87%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

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

@github-actions

github-actions Bot commented Mar 24, 2026

Copy link
Copy Markdown
PR Preview Action v1.6.1

🚀 View preview at
https://fulcrumgenomics.github.io/fgbio/pr-preview/pr-1148/

Built to branch gh-pages at 2026-03-25 19:20 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@TimD1
TimD1 marked this pull request as ready for review March 26, 2026 20:54
@TimD1
TimD1 requested review from clintval, nh13 and tfenne as code owners March 26, 2026 20:54
@coderabbitai

coderabbitai Bot commented Mar 26, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

CorrectUmis was changed to accept fixed UMI sets of multiple lengths, grouping fixed UMIs by length and matching BAM UMIs only against same-length groups; metrics and unmatched placeholders are tracked per length. GroupReadsByUmi adds a truncate option and changes minUmiLength semantics: reads with UMIs shorter than minUmiLength are discarded unless truncate=true, and truncation (when enabled) trims UMIs to the explicit target length while preserving non-UMI separators. Tests were expanded for variable-length UMIs and truncation behavior.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: allow variable length UMIs' directly and clearly summarizes the main change across all modified files, which collectively implement support for variable-length UMIs.
Description check ✅ Passed The description appropriately contextualizes the PR as continuing prior work on variable-length UMIs, referencing related issues and PRs, and is clearly related to the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ 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 557_td_allow-varable-length-umis

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.

@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)
src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala (1)

851-862: ⚠️ Potential issue | 🟠 Major

Truncation is still using raw string width.

Line 862 slices characters, but the new contract on Lines 562-565 defines UMI length by bases and excludes separators. ACT-GGG with --min-umi-length=6 is already 6bp; this code turns it into ACT-GG, which can merge families incorrectly when --truncate=true.

Possible fix
+  private def truncateUmi(umi: Umi, targetLength: Int): Umi = {
+    val out = new StringBuilder()
+    var keptBases = 0
+    umi.foreach { c =>
+      val isUmiBase = SequenceUtil.isUpperACGTN(c.toUpper.toByte)
+      if (!isUmiBase || keptBases < targetLength) out.append(c)
+      if (isUmiBase) keptBases += 1
+    }
+    out.toString
+  }
+
   private def truncateUmis(umis: Seq[Umi]): Seq[Umi] = this.minUmiLength match {
     case None => umis
     case Some(length) =>
       this.assigner match {
         case _: PairedUmiAssigner =>
           throw new IllegalStateException("Cannot used the paired umi assigner when min-umi-length is defined.")
         case _ =>
           val minLength = umis.map(_.length).min
           require(length <= minLength, s"Bug: UMI found that had shorter length than expected ($minLength < $length)")
-          umis.map(_.substring(0, length))
+          umis.map(umi => truncateUmi(umi, length))
       }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala` around lines
851 - 862, The truncateUmis method currently measures and slices UMIs by raw
string length and substring (using Umi.length and .substring), which incorrectly
counts separators (e.g. '-' ) and corrupts base-count truncation; update
truncateUmis to compute lengths in bases (excluding separators) and truncate
each Umi by base count while preserving separators/structure instead of using
raw substring. Specifically, when matching minUmiLength/Some(length) and for
non-PairedUmiAssigner cases, compute minLength as the minimum number of bases in
each Umi (not raw string chars) and map each Umi to a new Umi truncated to the
first N bases (preserving separators), e.g. by adding/using a Umi method that
returns the first N bases or by iterating bases of each Umi and reconstructing
the truncated Umi.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala`:
- Around line 562-565: The help text incorrectly implies only truncation is
unsupported for paired UMIs, but the code (Strategy.Paired rejection when
--min-umi-length is set) actually disallows any min-umi-length filtering in
paired mode; update the help strings in GroupReadsByUmi.scala (the
UMI-length/--min-umi-length/--truncate help paragraphs and the similar block
around lines ~597-601) to explicitly state that --min-umi-length is not
supported for Strategy.Paired (i.e., paired UMIs cannot use min-umi-length or
truncation), so the documentation matches the runtime check that rejects
Strategy.Paired with --min-umi-length.

---

Outside diff comments:
In `@src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala`:
- Around line 851-862: The truncateUmis method currently measures and slices
UMIs by raw string length and substring (using Umi.length and .substring), which
incorrectly counts separators (e.g. '-' ) and corrupts base-count truncation;
update truncateUmis to compute lengths in bases (excluding separators) and
truncate each Umi by base count while preserving separators/structure instead of
using raw substring. Specifically, when matching minUmiLength/Some(length) and
for non-PairedUmiAssigner cases, compute minLength as the minimum number of
bases in each Umi (not raw string chars) and map each Umi to a new Umi truncated
to the first N bases (preserving separators), e.g. by adding/using a Umi method
that returns the first N bases or by iterating bases of each Umi and
reconstructing the truncated Umi.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e31c5897-da08-45f5-8074-f251655d9ee8

📥 Commits

Reviewing files that changed from the base of the PR and between c58bd9c and 2c988b2.

📒 Files selected for processing (4)
  • src/main/scala/com/fulcrumgenomics/umi/CorrectUmis.scala
  • src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala
  • src/test/scala/com/fulcrumgenomics/umi/CorrectUmisTest.scala
  • src/test/scala/com/fulcrumgenomics/umi/GroupReadsByUmiTest.scala

Comment thread src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala Outdated

@nh13 nh13 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you also fix up any coderabbitai suggestions?

Comment thread src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala Outdated
Comment thread src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala Outdated
Comment thread src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala Outdated
Comment thread src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala Outdated
Comment thread src/test/scala/com/fulcrumgenomics/umi/GroupReadsByUmiTest.scala Outdated
Comment thread src/test/scala/com/fulcrumgenomics/umi/GroupReadsByUmiTest.scala Outdated
Comment thread src/main/scala/com/fulcrumgenomics/umi/CorrectUmis.scala Outdated
Comment thread src/test/scala/com/fulcrumgenomics/umi/CorrectUmisTest.scala
Comment thread src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala Outdated
Comment thread src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala Outdated

@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

♻️ Duplicate comments (1)
src/main/scala/com/fulcrumgenomics/umi/CorrectUmis.scala (1)

260-266: ⚠️ Potential issue | 🟠 Major

Still guard the zero-denominator cases in metric finalization.

The new numReal == 0 check helps, but Line 265 can still divide by 0 when every read is rejected, and Line 266 can still divide by 0 when only unmatched buckets accumulate counts. That will write NaN/Infinity into the metrics file.

Proposed fix
     val sortedMetrics        = umiMetrics.values.toSeq.sortBy(_.umi)
     val totalWithUnmatched   = sortedMetrics.map(_.total_matches).sum.toDouble
     val numReal = sortedMetrics.size - unmatchedUmiByLength.size
     val meanWithoutUnmatched = if (numReal == 0) 0d else sortedMetrics.filter(m => m.umi != unmatchedUmiByLength(m.umi.length)).map(_.total_matches).sum / numReal.toDouble
     sortedMetrics.foreach { m =>
-      m.fraction_of_matches = m.total_matches / totalWithUnmatched
-      m.representation      = m.total_matches / meanWithoutUnmatched
+      m.fraction_of_matches = if (totalWithUnmatched == 0d) 0d else m.total_matches / totalWithUnmatched
+      m.representation      = if (meanWithoutUnmatched == 0d) 0d else m.total_matches / meanWithoutUnmatched
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/scala/com/fulcrumgenomics/umi/CorrectUmis.scala` around lines 260 -
266, The finalization can still divide by zero: compute totalWithUnmatched and
meanWithoutUnmatched as before but when populating each UMI metric (in the loop
over sortedMetrics) guard the two divisions by checking denominators; if
totalWithUnmatched == 0 set m.fraction_of_matches = 0.0, and if
meanWithoutUnmatched == 0 set m.representation = 0.0 (otherwise compute as
m.total_matches / denominator). Keep using the existing symbols (sortedMetrics,
totalWithUnmatched, meanWithoutUnmatched, unmatchedUmiByLength, m.total_matches,
m.fraction_of_matches, m.representation) so no other logic changes are required.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala`:
- Around line 607-608: The validation currently allows zero or negative values
for minUmiLength which breaks truncation logic; update the require checks around
minUmiLength/truncate in GroupReadsByUmi (the require guarding Strategy.Paired
and the require(!truncate || minUmiLength.isDefined)) to also reject
non-positive values by asserting minUmiLength.forall(_ > 0) (and where checking
definedness ensure minUmiLength.exists(_ > 0)), so that --min-umi-length must be
> 0 when provided and truncate cannot be used without a positive minUmiLength.
- Around line 817-835: In the non-paired branch of the rawToId computation (the
assigner match fallback that calls this.assigner.assign/_umis), replace
u.split("-") with u.split("-", -1) so that trailing empty UMI segments are
preserved (e.g., "ACGT-" remains distinct from "ACGT"); this ensures groupBy(u
=> u.split("-", -1).map(_.length).toSeq) produces correct length buckets for
AdjacencyUmiAssigner and prevents mixing raw UMI lengths.

---

Duplicate comments:
In `@src/main/scala/com/fulcrumgenomics/umi/CorrectUmis.scala`:
- Around line 260-266: The finalization can still divide by zero: compute
totalWithUnmatched and meanWithoutUnmatched as before but when populating each
UMI metric (in the loop over sortedMetrics) guard the two divisions by checking
denominators; if totalWithUnmatched == 0 set m.fraction_of_matches = 0.0, and if
meanWithoutUnmatched == 0 set m.representation = 0.0 (otherwise compute as
m.total_matches / denominator). Keep using the existing symbols (sortedMetrics,
totalWithUnmatched, meanWithoutUnmatched, unmatchedUmiByLength, m.total_matches,
m.fraction_of_matches, m.representation) so no other logic changes are required.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b6a41b99-aa67-45ef-b8bd-47a81b2c5984

📥 Commits

Reviewing files that changed from the base of the PR and between 2c988b2 and 8424cfb.

📒 Files selected for processing (4)
  • src/main/scala/com/fulcrumgenomics/umi/CorrectUmis.scala
  • src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala
  • src/test/scala/com/fulcrumgenomics/umi/CorrectUmisTest.scala
  • src/test/scala/com/fulcrumgenomics/umi/GroupReadsByUmiTest.scala
✅ Files skipped from review due to trivial changes (1)
  • src/test/scala/com/fulcrumgenomics/umi/GroupReadsByUmiTest.scala
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/test/scala/com/fulcrumgenomics/umi/CorrectUmisTest.scala

Comment thread src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala
Comment on lines +817 to +835
val rawToId = this.assigner match {
case paired: PairedUmiAssigner =>
umis
.groupBy { u =>
val segs = u.split("-")
val aLen = segs.find(_.startsWith(paired.lowerReadUmiPrefix + ":")).map(_.stripPrefix(paired.lowerReadUmiPrefix + ":").length).getOrElse(throw new IllegalStateException(s"UMI segment missing expected prefix '${paired.lowerReadUmiPrefix}' in: $u"))
val bLen = segs.find(_.startsWith(paired.higherReadUmiPrefix + ":")).map(_.stripPrefix(paired.higherReadUmiPrefix + ":").length).getOrElse(throw new IllegalStateException(s"UMI segment missing expected prefix '${paired.higherReadUmiPrefix}' in: $u"))
(aLen, bLen)
}
.values
.flatMap { umisByLength => paired.assign(umisByLength) }
.toMap
case _ =>
umis
.groupBy(u => u.split("-").map(_.length).toSeq)
.values
.flatMap { _umis => this.assigner.assign(_umis) }
.toMap
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Scala/Java, does String.split("-")drop trailing empty fields, and doessplit("-", -1) preserve them?

💡 Result:

Yes.

  • Java / Scala (java.lang.String): str.split("-") is defined as str.split("-", 0), and with limit = 0 Java discards trailing empty strings (so "a-b-".split("-") yields Array("a","b")). [1]
  • str.split("-", -1) uses a negative limit, meaning the pattern is applied as many times as possible and trailing empty strings are preserved (so "a-b-".split("-", -1) yields Array("a","b","")). [1]

In Scala StringOps, split(separator: Char) likewise excludes empty trailing substrings and is implemented in terms of Java’s String.split. [2]

Sources: [1] (docs.oracle.com), [2] (scala-lang.org)

Citations:


🏁 Script executed:

# First, locate and check the exact code at line 831
rg -n "split\(\"-\"\)" src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala -A 2 -B 2

Repository: fulcrumgenomics/fgbio

Length of output: 890


🏁 Script executed:

# Search for CorrectUmis file and its split usage
fd -i correctumis src/main/scala --type f

Repository: fulcrumgenomics/fgbio

Length of output: 121


🏁 Script executed:

# Check if CorrectUmis uses split("-", -1)
rg -n "split\(\"-\"" src/main/scala/com/fulcrumgenomics/umi/ -l

Repository: fulcrumgenomics/fgbio

Length of output: 318


🏁 Script executed:

# Check CorrectUmis.scala for split("-", -1) usage
rg -n "split\(" src/main/scala/com/fulcrumgenomics/umi/CorrectUmis.scala -B 1 -A 1

Repository: fulcrumgenomics/fgbio

Length of output: 308


Preserve trailing empty UMI segments in non-paired case.

Line 831 uses split("-"), which drops trailing empty segments. A tag like ACGT- then collapses into the same bucket as ACGT, causing mixed raw string lengths to trip AdjacencyUmiAssigner's equal-length precondition. CorrectUmis already uses split("-", -1) for the same tag shape.

Proposed fix
         case _ =>
           umis
-            .groupBy(u => u.split("-").map(_.length).toSeq)
+            .groupBy(u => u.split("-", -1).map(_.length).toSeq)
             .values
             .flatMap { _umis => this.assigner.assign(_umis) }
             .toMap
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val rawToId = this.assigner match {
case paired: PairedUmiAssigner =>
umis
.groupBy { u =>
val segs = u.split("-")
val aLen = segs.find(_.startsWith(paired.lowerReadUmiPrefix + ":")).map(_.stripPrefix(paired.lowerReadUmiPrefix + ":").length).getOrElse(throw new IllegalStateException(s"UMI segment missing expected prefix '${paired.lowerReadUmiPrefix}' in: $u"))
val bLen = segs.find(_.startsWith(paired.higherReadUmiPrefix + ":")).map(_.stripPrefix(paired.higherReadUmiPrefix + ":").length).getOrElse(throw new IllegalStateException(s"UMI segment missing expected prefix '${paired.higherReadUmiPrefix}' in: $u"))
(aLen, bLen)
}
.values
.flatMap { umisByLength => paired.assign(umisByLength) }
.toMap
case _ =>
umis
.groupBy(u => u.split("-").map(_.length).toSeq)
.values
.flatMap { _umis => this.assigner.assign(_umis) }
.toMap
}
val rawToId = this.assigner match {
case paired: PairedUmiAssigner =>
umis
.groupBy { u =>
val segs = u.split("-")
val aLen = segs.find(_.startsWith(paired.lowerReadUmiPrefix + ":")).map(_.stripPrefix(paired.lowerReadUmiPrefix + ":").length).getOrElse(throw new IllegalStateException(s"UMI segment missing expected prefix '${paired.lowerReadUmiPrefix}' in: $u"))
val bLen = segs.find(_.startsWith(paired.higherReadUmiPrefix + ":")).map(_.stripPrefix(paired.higherReadUmiPrefix + ":").length).getOrElse(throw new IllegalStateException(s"UMI segment missing expected prefix '${paired.higherReadUmiPrefix}' in: $u"))
(aLen, bLen)
}
.values
.flatMap { umisByLength => paired.assign(umisByLength) }
.toMap
case _ =>
umis
.groupBy(u => u.split("-", -1).map(_.length).toSeq)
.values
.flatMap { _umis => this.assigner.assign(_umis) }
.toMap
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/scala/com/fulcrumgenomics/umi/GroupReadsByUmi.scala` around lines
817 - 835, In the non-paired branch of the rawToId computation (the assigner
match fallback that calls this.assigner.assign/_umis), replace u.split("-") with
u.split("-", -1) so that trailing empty UMI segments are preserved (e.g.,
"ACGT-" remains distinct from "ACGT"); this ensures groupBy(u => u.split("-",
-1).map(_.length).toSeq) produces correct length buckets for
AdjacencyUmiAssigner and prevents mixing raw UMI lengths.

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.

2 participants