Skip to content

feat(extract): generalize read-name UMI extraction into a delimited-field "read-name structure" #477

Description

@nh13

Summary

fgumi extract --extract-umis-from-read-names currently hard-codes a single read-name layout: it splits the name on :, requires at least 8 fields, and takes the UMI from the last field. This is the standard Illumina shape (instrument:run:flowcell:lane:tile:x:y:UMI) and nothing else. Any other demultiplexer that encodes UMIs in the read name — with a different field count, more than one UMI field, or a "missing UMI" sentinel — either silently yields no UMI or extracts the wrong one.

This proposes replacing the fixed rule with a small, declarative read-name structure: the read-name analog of fgbio/fgumi read-structures, but the unit is a delimited field rather than a base. The user describes where the UMIs are; extract stays format-agnostic. Named fast-path presets can be layered on later as thin aliases that expand to a structure string, so we get generality now without foreclosing terse shortcuts.

Motivation

The concrete driver is UMI-bearing reads from non-Illumina demultiplexers whose read names place the UMI outside the last field, or use two UMI fields (5′ and 3′), or use a sentinel such as * for an unclassified UMI. For example, a name of the form …:<field>:<umi5p>:<umi3p> carries two trailing UMI fields, each of which may be *. Today extract -n would take only the final field and treat * as literal UMI bases.

We deliberately want extract to encode no vendor-specific format. The layout should be supplied at runtime, so fgumi remains a neutral, general-purpose tool and can be pointed at any read-name convention without baking a specific one into the codebase.

Current behavior (for reference)

src/lib/commands/extract.rs, extract_read_name_and_umi:

  • split the name (up to the first space) on :
  • if parts.len() >= 8, take parts.last() as the UMI; normalize +-
  • otherwise return no UMI

Limitations:

  • single UMI only (last field)
  • fixed >= 8 field guard; both under- and over-counting silently produce no UMI
  • no notion of a "missing UMI" sentinel
  • delimiter is fixed to :

Proposed solution

A new option that describes the name as an ordered list of delimited-field segments:

--read-name-structure <STRUCTURE>     # e.g. '+S 2M'
--read-name-delimiter <CHAR>          # default ':'
--read-name-missing <TOKEN>           # optional sentinel meaning "field present but UMI unclassified", e.g. '*'

Structure grammar (mirrors read-structures, over fields instead of bases):

  • a whitespace-optional sequence of <count><op> segments
  • ops: S = skip field(s), M = UMI field(s)
  • exactly one segment may use + instead of a count, meaning "however many fields are left over"; unlike read-structures (where + is always terminal), + may appear anywhere so the fixed part can anchor to the end of the name — this matters because leading fields are the ones that drift or get stripped across pipeline stages, while trailing UMI fields are stable

Semantics:

  • split the name (truncated at the first space) on --read-name-delimiter
  • solve the single + segment from the actual field count, then map fields to segments left-to-right
  • collect M fields in order; a field equal to --read-name-missing contributes no UMI
  • the resulting UMI(s) join with - into RX, consistent with how seq-derived UMIs are already combined
  • if UMI segments are ALSO present in the sequence read-structures, name-UMIs are prepended, then -, then seq-UMIs — unchanged from today

Mismatch handling: if the field count cannot satisfy the fixed segments, fail with a clear error naming the offending read (fail-fast, matching fgbio ExtractUmisFromBam), rather than the current silent drop. A lenient "skip non-conforming names" mode can be a follow-up if needed.

Examples

Two trailing UMI fields with a * sentinel (skip everything before them):

fgumi extract -i r1.fq.gz -o out.bam \
  --read-name-structure '+S 2M' \
  --read-name-missing '*'
# name '…:<field>:12:34'  -> RX = 12-34
# name '…:<field>:*:34'   -> RX = 34

Standard Illumina 8-field name, UMI in the last field:

fgumi extract -i r1.fq.gz -o out.bam --read-name-structure '+S 1M'

Interior UMI (not trailing), e.g. two UMI fields followed by a trailing checksum field:

--read-name-structure '4S 2M +S'

Back-compat

Keep --extract-umis-from-read-names / -n working exactly as it does today (the >= 8 guard + last-field rule). It can either remain a standalone code path or be reimplemented as sugar that preserves the guard. The new --read-name-structure is the general mechanism; the two are mutually exclusive.

Extensibility: fast-path presets (future)

Because a preset is just a structure string plus a delimiter and sentinel, named shortcuts can be added later as pure data with no new parsing logic, e.g.:

--read-name-preset <name>   ==   --read-name-structure '…' --read-name-delimiter … --read-name-missing …

This keeps the door open for terse per-vendor shortcuts without encoding any single format as the primitive.

Open questions

  1. Missing-UMI representation. Drop missing M fields from RX (proposed), or retain a placeholder to preserve 5′/3′ positional identity for duplex? Dropping is simpler; retaining preserves strand position. Preference?
  2. All-missing case. If every M field equals the sentinel, set no RX (matches today's no-UMI outcome) — agree?
  3. +-anywhere vs terminal-only. Is relaxing read-structures' "+ is terminal" rule acceptable here, or would a dedicated right-anchor syntax be clearer?
  4. Canonicalization. Leave any UMI reordering (e.g. lexical-min of the two UMIs) to group/correct downstream (proposed), rather than in extract?

Out of scope

  • UMI sequence extraction from bases (already handled by read-structures)
  • any vendor-specific format detection or auto-sniffing
  • changes to group/correct/consensus behavior

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions