Skip to content

fix(extract): truncate read names at any whitespace and validate UMI field count - #1040

Merged
nh13 merged 1 commit into
mainfrom
nh/extract-read-name-whitespace
Oct 8, 2026
Merged

nh13 merged 1 commit into
mainfrom
nh/extract-read-name-whitespace

Conversation

@nh13

@nh13 nh13 commented Oct 7, 2026

Copy link
Copy Markdown
Member

Summary

strip_read_suffix cut a FASTQ header only at the first space. A comment separated by a TAB, such as the tags samtools fastq -T RX,BC writes (name\tRX:Z:ACGT\tBC:Z:TTTT), stayed in the QNAME. With --extract-umis-from-read-names, RX was then taken from the last : field of that comment, which is the sample barcode. The name is now cut at the first whitespace character, using Java's Character.isWhitespace over ASCII (space, TAB, LF, VT, FF, CR, 0x1C-0x1F), as fgbio's FastqSource does. Extract and the paired-FASTQ mate-name sync checks in the FASTQ zip step and FastqGrouper all use this helper, so they change together. For the sync checks this only accepts more input: any pair that matched before still matches.

A header with no name before its first whitespace (@\tRX:Z:ACGT, @ 1:N:0:ACGT), or whose name is only a /1 or /2 suffix, used to be written with an empty QNAME, which the SAM spec does not allow. Mates with different names that both stripped to "" also passed the sync check. This is now an error that quotes the header, with or without -n. The guard is a new canonical_read_name helper used by the zip step, FastqGrouper and the extract record builder, so every FASTQ reader path enforces it, including de-interleaved input. The parsers still keep the raw header as is. fgbio accepts these headers: FastqSource.scala:111-120 yields "", FastqSource.zipped (:72) compares "" == "", FastqToBam.scala:196 sets the name, and htsjdk's SAMRecord.setReadName and BAMRecordCodec.encode (:176) write it unchecked. This is a deliberate divergence.

With --extract-umis-from-read-names, a name with 6 or fewer :-separated fields used to be written without a UMI and without any warning. It is now rejected with a message adapted from fgbio's strict-mode one, Trying to extract UMI from read with <n> colon-separated fields; expected at least 7 (7 = no UMI, 8 or more = UMI in the last field). Check the read-name format, or omit --extract-umis-from-read-names if the read names carry no UMI. Read name: <name>, where <name> is the read name without its comment. The hint comes before the name so it cannot be misread as part of it. A 7-field name still has no UMI, and an 8-field name takes the last field as its UMI. Two behaviors are unchanged and differ from fgbio on purpose. A name with 9 or more fields takes its last field, where fgbio throws. An empty last field gives no UMI from the name. fgbio extracts an empty UMI, so it writes an empty RX (or -<read UMI> with M segments), where fgumi writes no RX (or the read UMI alone).

Several doc comments said fgbio's extractor always returns the last field. That is only true of its non-strict mode (CopyUmiFromReadName). FastqToBam uses strict mode. Those comments are fixed, as are the copy-umi module doc and the deinterleave module doc, function doc and test comment, which described the old rules or implied a UMI could come from the comment. The field-count rules, the empty-last-field case and the whitespace cut are now documented in extract --help, the Extract rustdoc, and a new "UMIs From Read Names" section in the fgbio migration guide.

Changes output for:

  • extract when a FASTQ header's comment is separated by a TAB or another whitespace character that is not a space. The QNAME is shorter, and with -n the RX comes from the name and not from the comment.
  • extract -n when a read name has 6 or fewer fields. The run now fails with an error, where before the reads were written without RX.
  • extract when a header has no read name before its first whitespace, or only a /1 or /2 suffix. The run now fails with an error, where before the record was written with an empty QNAME.
  • Paired extract (and FastqGrouper) when mates have the same name but different TAB-separated comments. These pairs used to fail with a read name mismatch. They are now accepted.

fgbio parity (e51a661)

  • FastqSource.scala:111 splits the header at header.indexWhere(_.isWhitespace) and then strips a trailing / and one digit.
  • Umis.scala:91-95, strict mode: 6 colons gives None, 7 colons gives the last field, and any other count throws IllegalArgumentException with Trying to extract UMI from read with <n> parts (7-8 expected): <name>. FastqToBam.scala:188 calls it with strict=true on the first read's name.
  • FastqToBamTest.scala:358 ("extract UMIs from read names when requested"). This test is ported with TAB comments added to the headers, and it runs single-end and paired, with and without threads.
  • UmisTest.scala:47 ("return None if the read name has only 7 parts in strict mode") and UmisTest.scala:51 ("throw an exception in strict mode if the read has too many or too few segments"). The 1:2:3:4:5:6 case is ported. Its 1:2:3:4:5:6:7:8:ACGT case is the 9+-field divergence described above.

Tests

  • test_strip_read_suffix: new cases for TAB comments, TAB combined with a space and with /1, both ends of each delimiter range, and bytes that must stay in the name (!, A, ~, NUL, 0x08, 0x0E, 0x1B, DEL, 0x80, 0x85, 0xA0, 0xFF).
  • test_is_read_name_delimiter_matches_java_is_whitespace_over_bytes: pins the delimiter set over all 256 bytes.
  • canonical_read_name unit tests: a leading TAB, a leading space, /1 and an empty header are errors.
  • zip_step_and_grouper_classify_read_names_identically: guard-set parity. One case table runs through the zip step (record-by-record parse) and FastqGrouper (batched parse, with each stream split mid-record), including mismatched mates that used to collapse to "".
  • test_extract_read_name_and_umi_truncates_name_at_tab: the name and the UMI, with and without -n.
  • test_extract_read_name_and_umi_errors_on_six_or_fewer_fields: asserts the full error message for 6, 5, 2 and 1 fields, and for a 6-field name followed by a space or TAB comment.
  • Unit tests for a short name without -n (no error) and for an empty 8th field (no UMI).
  • Execute-level tests: TAB comments (with a comment-free variant, which replaces the old test_extract_umis_from_read_names), run single-end and paired (with R1 and R2 comments that differ), the 6-field error, and the empty-name error for a leading TAB, a leading space, mismatched paired mates and interleaved mates, with and without -n. All run single-threaded and with 2 threads.

Mutation-checked:

  • With the delimiter reverted to space-only, 14 of the targeted tests fail. The paired cases fail in the zip step with FASTQ read name mismatch.
  • With the field-count check removed, every error case fails.
  • With the threshold raised to 8, the 7-field cases fail.
  • With the empty-name guard disabled, the empty-name unit, parity and execute tests fail.

Checks

cargo ci-fmt, cargo ci-lint, cargo ci-doc, cargo ci-doctest, cargo ci-tag-literals and cargo ci-test (11294 passed, 31 skipped) all pass.

…field count

strip_read_suffix cut the FASTQ header only at the first space. A
TAB-separated comment, such as the tags `samtools fastq -T RX,BC` writes
(`name\tRX:Z:ACGT\tBC:Z:TTTT`), stayed in the QNAME. With
--extract-umis-from-read-names, RX was then taken from the last `:` field
of that comment, which is the sample barcode. The name is now cut at the
first whitespace character as defined by Java's Character.isWhitespace
over ASCII, matching fgbio FastqSource.scala:111
(`header.indexWhere(_.isWhitespace)`). The change applies to both extract
and the paired-FASTQ sync validation, which share the helper.

A header with no name before its first whitespace (`@\tRX:Z:ACGT`,
`@ 1:N:0:ACGT`) or whose name is only a `/1` or `/2` suffix used to be
written with an empty QNAME, which the SAM spec does not allow. Mates
with different names that both stripped to "" also passed the pair-sync
check. It is now an error quoting the header, with or without -n. The
check lives in a new canonical_read_name helper used by the pipeline zip
step, FastqGrouper and the extract record builder, so every FASTQ reader
path (including de-interleaved input) enforces it. fgbio accepts these
headers and writes the empty name.

With --extract-umis-from-read-names, a name with 6 or fewer `:`-separated
fields used to be written silently without a UMI. It is now rejected with
a message adapted from fgbio's strict-mode one (Umis.scala:95): "Trying
to extract UMI from read with <n> colon-separated fields; expected at
least 7 (7 = no UMI, 8 or more = UMI in the last field). Check the
read-name format, or omit --extract-umis-from-read-names if the read
names carry no UMI. Read name: <name>". fgbio's "(7-8 expected)" would be
wrong here because fgumi accepts 9 or more fields. A 7-field name still
has no UMI and an 8-field name takes its last field. A name with 9 or
more fields also takes its last field, a deliberate divergence from
fgbio, which throws. An empty last field still gives no UMI from the name
(EXT3-09), where fgbio extracts an empty one.

Corrects comments that said fgbio's extractor always returns the last
field. That is true only of its non-strict mode, while FastqToBam uses
strict mode. Documents the field-count rules, the empty-last-field case
and the whitespace cut in the extract --help and the fgbio migration
guide, and corrects the copy-umi module doc and the deinterleave docs and
test comment that described the old rules.

Changes output for: extract with FASTQ headers whose comment is separated
by a TAB or another non-space whitespace character (shorter QNAME; with
-n, RX now comes from the name rather than the comment), extract -n with
read names of 6 or fewer fields (now an error rather than reads written
without RX), and extract with a header that has no read name (now an
error rather than a record with an empty QNAME).
@nh13
nh13 deployed to github-actions October 7, 2026 09:01 — with GitHub Actions Active
@coderabbitai

coderabbitai Bot commented Oct 7, 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: b0563634-aa75-447b-9267-67c719d4e706
📥 Commits

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

📒 Files selected for processing (7)
  • docs/src/guide/migration-from-fgbio.md
  • src/lib/commands/copy_umi.rs
  • src/lib/commands/extract.rs
  • src/lib/fastq_deinterleave.rs
  • src/lib/fastq_parse.rs
  • src/lib/grouper.rs
  • src/lib/pipeline/steps/source/fastq_zip.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.


⚠️ A high-level summary could not be generated for this review. CodeRabbit will regenerate it on the next update, or you can request a refresh with @coderabbitai summary.

Walkthrough

FASTQ read-name handling now strips whitespace-delimited comments and rejects empty canonical names. UMI extraction applies field-count rules to canonical names. The grouper and pipeline zipper compare records using those names.

Changes

Read-name handling

Layer / File(s) Summary
Canonical read-name rules
src/lib/fastq_parse.rs
The parser recognizes ASCII whitespace delimiters when stripping comments. The new canonical_read_name function returns an error when normalization produces an empty name. Tests cover delimiter bytes, suffix handling, and empty names.
Read-name UMI extraction
src/lib/commands/extract.rs, src/lib/commands/copy_umi.rs, src/lib/fastq_deinterleave.rs, docs/src/guide/migration-from-fgbio.md
Extraction canonicalizes names and rejects empty names. With UMI extraction enabled, names with six or fewer fields produce an error; seven fields or an empty final field produce no name-derived UMI; names with eight or more fields use the final field. Documentation and tests cover these rules and whitespace-delimited comments.
Paired-stream name matching
src/lib/grouper.rs, src/lib/pipeline/steps/source/fastq_zip.rs
Both paths canonicalize names before comparing records. Empty names produce InvalidData errors with stream context. Tests compare the paths for valid names, mismatches, and empty names.

Priority: ➖ Normal

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

Change: Bug fix

Suggested labels: fgumi group

Merge Risk: ⚪ Minimal · up to da8a5

The described read-name and UMI rules are consistent across extraction and paired-read matching. No specific issue remains to resolve before normal merge checks.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title uses valid Conventional Commit syntax, names the affected extract command, uses a lowercase imperative description, and accurately summarizes the main read-name and UMI field-count changes.
  • 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 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai pause

@coderabbitai

coderabbitai Bot commented Oct 7, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews paused.

@codecov

codecov Bot commented Oct 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.24242% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 96.45%. Comparing base (62b98c0) to head (da8a52b).

Files with missing lines Patch % Lines
src/lib/pipeline/steps/source/fastq_zip.rs 97.67% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1040      +/-   ##
==========================================
- Coverage   96.47%   96.45%   -0.03%     
==========================================
  Files         299      299              
  Lines      152214   152284      +70     
==========================================
+ Hits       146854   146888      +34     
- Misses       5360     5396      +36     

☔ 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 commented Oct 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed the last commit. Use @coderabbitai full review to rerun a review of the entire changeset.

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 72f681a Oct 8, 2026
21 checks passed
@nh13
nh13 deleted the nh/extract-read-name-whitespace branch October 8, 2026 01:04
@nh13 nh13 mentioned this pull request Oct 8, 2026

This branch was successfully deployed

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