From 10d12959d366dd389e75e354d820bd249ab125d4 Mon Sep 17 00:00:00 2001 From: "Mitchell R. Vollger" Date: Fri, 18 Sep 2026 08:26:35 -0600 Subject: [PATCH 1/6] fix: drop annotations that do not fit SEQ instead of panicking in ft fire Hard-clipped supplementary alignments keep the full-length read's nuc/msp/m6a tags, so coordinates run past the clipped SEQ and, flipped for a reverse strand, wrap below zero as a u32. ft fire panicked slicing SEQ with them (#136), and ft extract printed misplaced positions for forward reads and u32-wrapped ones for reverse reads. The reader now treats such a record as untagged: one warning, annotations cleared, record kept. Every command agrees and the record passes through ft fire unchanged. Closes #136 --- src/fiber.rs | 12 +++++++ src/utils/ma_io.rs | 35 ++++++++++++++++++++ tests/data/ont_hardclip_supplementary.bam | Bin 0 -> 43744 bytes tests/regression/extract.rs | 38 ++++++++++++++++++++++ tests/regression/fire.rs | 37 +++++++++++++++++++++ 5 files changed, 122 insertions(+) create mode 100644 tests/data/ont_hardclip_supplementary.bam diff --git a/src/fiber.rs b/src/fiber.rs index 71a59b3ba..4a123929b 100644 --- a/src/fiber.rs +++ b/src/fiber.rs @@ -50,6 +50,18 @@ impl FiberseqData { MolecularAnnotations::from_record(&record) }); + // Tags from another frame (hard-clipped supplementary alignments keep + // the full-length read's tags, #136) would index past SEQ. Treat the + // record as untagged instead of emitting misplaced or wrapped + // coordinates, or panicking in a consumer. + if let Some(why) = crate::utils::ma_io::stale_frame_reason(&annotations, &record) { + log::warn!( + "dropping annotations for {}: {why} (hard-clipped supplementary alignment?)", + String::from_utf8_lossy(record.qname()) + ); + annotations.annotation_types.clear(); + } + // Backfill or recalculate the callable state per the CLI minimums, // before any consumer-side pruning; see sync_fiberseq_callable. crate::utils::ma_io::sync_fiberseq_callable(&mut annotations, &record, filters); diff --git a/src/utils/ma_io.rs b/src/utils/ma_io.rs index 4f476abb7..1535b76a1 100644 --- a/src/utils/ma_io.rs +++ b/src/utils/ma_io.rs @@ -105,6 +105,41 @@ pub(crate) fn read_length_is_stale(read_length: u32, seq_len: usize) -> bool { seq_len > 0 && read_length as usize != seq_len } +/// Why a record's annotations do not fit its SEQ, or `None` when they do. +/// Hard-clipped supplementary alignments keep the full-length read's tags +/// (MA or legacy), so coordinates run past the clipped SEQ and, once flipped +/// for a reverse strand, wrap below zero (#136). SEQ-less records are never +/// stale: the MA read length is the frame. +pub(crate) fn stale_frame_reason( + annot: &MolecularAnnotations, + record: &bam::Record, +) -> Option { + let seq_len = record.seq_len(); + if seq_len == 0 { + return None; + } + if read_length_is_stale(annot.read_length, seq_len) { + return Some(format!( + "MA read length {} does not match the {seq_len} bp sequence", + annot.read_length + )); + } + annot + .annotation_types + .iter() + .find(|t| { + t.annotations + .iter() + .any(|a| a.start as usize + a.length as usize > seq_len) + }) + .map(|t| { + format!( + "{} coordinates extend past the {seq_len} bp sequence", + t.name + ) + }) +} + /// True when the callable state can be derived: calling ran (nuc or msp /// present) and the frame is not stale. Derivation is pure MA-tag /// arithmetic, so SEQ-less records derive fine. diff --git a/tests/data/ont_hardclip_supplementary.bam b/tests/data/ont_hardclip_supplementary.bam new file mode 100644 index 0000000000000000000000000000000000000000..edee745d01ccff129be326db9f60381a5c781069 GIT binary patch literal 43744 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 g03VA81ONa4009360763o02=@U00000000000Q9JRbpQYW literal 0 HcmV?d00001 diff --git a/tests/regression/extract.rs b/tests/regression/extract.rs index c09aa7f5a..d029a3ee4 100644 --- a/tests/regression/extract.rs +++ b/tests/regression/extract.rs @@ -101,3 +101,41 @@ fn extract_reads_ma_spelled_fixture() { let out = std::fs::read_to_string(tmp.path()).unwrap(); insta::assert_snapshot!(select_bed12_cols(&out, BED12_COLS)); } + +// The reader drops annotations that do not fit SEQ (hard-clipped supplementary +// reads keep the full-length read's tags, #136). Before this, extract printed +// misplaced coordinates for the forward read and u32-wrapped ones for the +// reverse read. +#[test] +fn extract_drops_annotations_that_exceed_the_sequence() { + let out = run(&[ + "extract", + "--all", + "-", + fixture("ont_hardclip_supplementary.bam").to_str().unwrap(), + ]); + let mut lines = out.lines(); + let header: Vec<&str> = lines.next().unwrap().split('\t').collect(); + let col = |name: &str| header.iter().position(|h| *h == name).unwrap(); + let (flag, nuc, msp, m6a) = ( + col("sam_flag"), + col("nuc_starts"), + col("msp_starts"), + col("m6a"), + ); + let mut n_primary = 0; + let mut n_supp = 0; + for line in lines { + let f: Vec<&str> = line.split('\t').collect(); + let supplementary = f[flag].parse::().unwrap() & 2048 != 0; + for c in [nuc, msp, m6a] { + assert_eq!(f[c] == ".", supplementary, "{}: {}", header[c], f[c]); + } + if supplementary { + n_supp += 1; + } else { + n_primary += 1; + } + } + assert_eq!((n_primary, n_supp), (2, 2)); +} diff --git a/tests/regression/fire.rs b/tests/regression/fire.rs index ab7e621e6..2c87ffa42 100644 --- a/tests/regression/fire.rs +++ b/tests/regression/fire.rs @@ -27,6 +27,43 @@ fn fire_on_legacy_input_strips_consumed_legacy_tags() { } } +// Hard-clipped supplementary alignments can keep nuc/msp tag coordinates +// from the full-length read, so positions run past the clipped SEQ (and wrap +// below zero when flipped on reverse-strand records). The reader drops those +// annotations, so `ft fire` has nothing to score and writes the records to +// the output unchanged instead of panicking (#136). The fixture holds two +// scorable primary reads plus a forward and a reverse hard-clipped +// supplementary read from TEnCATS ONT data. +#[test] +fn fire_skips_records_whose_coords_exceed_the_sequence() { + let scored = NamedTempFile::with_suffix(".bam").unwrap(); + run(&[ + "fire", + "--ont", + fixture("ont_hardclip_supplementary.bam").to_str().unwrap(), + scored.path().to_str().unwrap(), + ]); + let mut reader = bam::Reader::from_path(scored.path()).unwrap(); + let mut n_scored = 0; + let mut n_skipped = 0; + for rec in reader.records() { + let rec = rec.unwrap(); + if rec.is_supplementary() { + assert!(rec.aux(b"Ma").is_err(), "unscorable record got a Ma tag"); + assert!( + rec.aux(b"as").is_ok(), + "skipped record lost its original tags" + ); + n_skipped += 1; + } else { + assert!(rec.aux(b"Ma").is_ok(), "scorable record missing Ma tag"); + n_scored += 1; + } + } + assert_eq!(n_scored, 2); + assert_eq!(n_skipped, 2); +} + fn extract_fdrs(out: &str) -> Vec { out.lines() .map(|l| l.split('\t').nth(9).unwrap().parse().unwrap()) From 22ffadaf84a36b1660210e13efbb105264eeeb58 Mon Sep 17 00:00:00 2001 From: "Mitchell R. Vollger" Date: Fri, 18 Sep 2026 09:30:26 -0600 Subject: [PATCH 2/6] fix: drop stale-frame annotations in read_record, rate-limit the warning Review found convert-tags, strip-basemods, ddda-to-m6a and predict-m6a parse through ma_io::read_record directly and so still serialized the overrunning legacy coordinates into an MA tag. The check now runs inside read_record, so every parser path agrees. The warning prints for the first 10 records and then drops to debug, since ONT BAMs can hold many hard-clipped supplementary reads. --- src/fiber.rs | 12 ----------- src/utils/ma_io.rs | 36 +++++++++++++++++++++++++++++++- tests/regression/convert_tags.rs | 21 +++++++++++++++++++ 3 files changed, 56 insertions(+), 13 deletions(-) diff --git a/src/fiber.rs b/src/fiber.rs index 4a123929b..71a59b3ba 100644 --- a/src/fiber.rs +++ b/src/fiber.rs @@ -50,18 +50,6 @@ impl FiberseqData { MolecularAnnotations::from_record(&record) }); - // Tags from another frame (hard-clipped supplementary alignments keep - // the full-length read's tags, #136) would index past SEQ. Treat the - // record as untagged instead of emitting misplaced or wrapped - // coordinates, or panicking in a consumer. - if let Some(why) = crate::utils::ma_io::stale_frame_reason(&annotations, &record) { - log::warn!( - "dropping annotations for {}: {why} (hard-clipped supplementary alignment?)", - String::from_utf8_lossy(record.qname()) - ); - annotations.annotation_types.clear(); - } - // Backfill or recalculate the callable state per the CLI minimums, // before any consumer-side pruning; see sync_fiberseq_callable. crate::utils::ma_io::sync_fiberseq_callable(&mut annotations, &record, filters); diff --git a/src/utils/ma_io.rs b/src/utils/ma_io.rs index 1535b76a1..730de5552 100644 --- a/src/utils/ma_io.rs +++ b/src/utils/ma_io.rs @@ -78,9 +78,41 @@ pub fn read_record(record: &bam::Record) -> Result { merge_missing_types(&mut annot, read_legacy_fibertig(record)?); } } + drop_stale_frame(&mut annot, record); Ok(annot) } +/// How many stale-frame records get a WARN before the rest drop to DEBUG. +/// ONT BAMs can hold thousands of hard-clipped supplementary reads. +const STALE_FRAME_WARN_LIMIT: usize = 10; + +/// Treat a record whose tags come from another frame as untagged (#136). +/// Hard-clipped supplementary alignments keep the full-length read's tags, +/// which would index past SEQ: consumers panic, or emit misplaced and +/// u32-wrapped coordinates. Lives here so every path that parses a record +/// (the fiber reader, convert-tags, strip-basemods, ddda-to-m6a, predict-m6a) +/// sees the same thing. +fn drop_stale_frame(annot: &mut MolecularAnnotations, record: &bam::Record) { + use std::sync::atomic::{AtomicUsize, Ordering}; + static SEEN: AtomicUsize = AtomicUsize::new(0); + let Some(why) = stale_frame_reason(annot, record) else { + return; + }; + let n = SEEN.fetch_add(1, Ordering::Relaxed); + let qname = String::from_utf8_lossy(record.qname()); + if n < STALE_FRAME_WARN_LIMIT { + log::warn!( + "dropping annotations for {qname}: {why} (hard-clipped supplementary alignment?)" + ); + if n + 1 == STALE_FRAME_WARN_LIMIT { + log::warn!("further stale-frame records are logged at debug level"); + } + } else { + log::debug!("dropping annotations for {qname}: {why}"); + } + annot.annotation_types.clear(); +} + /// Make the fiberseq_callable annotation agree with the CLI minimums. /// Runs right after parsing, before any consumer-side pruning. Derives /// when the tag is absent (backfill) or the minimums are non-default @@ -976,7 +1008,9 @@ mod tests { #[test] fn rewrite_replaces_ma_tag_instead_of_appending() { - let mut record = synth_record(b"ATCGATCGAT"); + // 300 bp so the msps below fit SEQ; read_record drops annotations + // that run past the sequence (#136). + let mut record = synth_record(&b"ATCGATCGAT".repeat(30)); let qspec_q = "Q".parse::().unwrap(); // First write: one msp at 100..150. diff --git a/tests/regression/convert_tags.rs b/tests/regression/convert_tags.rs index c120c4b59..7d94996ef 100644 --- a/tests/regression/convert_tags.rs +++ b/tests/regression/convert_tags.rs @@ -172,3 +172,24 @@ fn convert_tags_migrates_uppercase_to_canonical() { assert_eq!(ml(b), ml(a), "ML changed"); } } + +// Hard-clipped supplementary reads keep the full-length read's legacy tags, +// which run past SEQ (#136). read_record drops those annotations, so +// convert-tags must not serialize the overrunning coordinates into an MA +// tag. The two primaries in the fixture convert normally. +#[test] +fn convert_tags_drops_annotations_that_exceed_the_sequence() { + let out = NamedTempFile::with_suffix(".bam").unwrap(); + convert(&fixture("ont_hardclip_supplementary.bam"), out.path()); + let mut n_supp = 0; + for rec in records(out.path()) { + let tag = ma(&rec).unwrap_or_default(); + // MA is ";": no sections means no annotations + let has_annotations = tag.trim_end_matches(';').contains(';'); + assert_eq!(!has_annotations, rec.is_supplementary(), "Ma tag {tag:?}"); + if rec.is_supplementary() { + n_supp += 1; + } + } + assert_eq!(n_supp, 2); +} From 6d10e36481b1a6376d8cf19e2f09e7e7c418e250 Mon Sep 17 00:00:00 2001 From: "Mitchell R. Vollger" Date: Fri, 18 Sep 2026 10:36:25 -0600 Subject: [PATCH 3/6] fix!: one frame check for hard-clipped records, keyed on the real signals Review of #138 found the check detected stale tags only by side effect (coordinates that happened to overrun SEQ), missed legacy-tagged records whose copied coordinates fit, left a second guard in BamChunk with a different policy, and never told users the aligner flag that avoids it. read_record now decides before parsing, per tag family: MA by its own read length, legacy ns/nl/as/al by any hard clip or missing SEQ, MM/ML by MN when present else hard clips; bounds stay as a backstop. A stale record is cleared, its frame reset to SEQ, and write_record strips every stale tag so ft fire, convert-tags and add-nucleosomes all leave an honest untagged read. The BamChunk skip is gone, so plain minimap2 output no longer loses whole records. The warning explains the cause once, names the remedy (pbmm2, dorado aligner -Y, minimap2 -Y -y, or -F 2048), and a total prints at exit. ft qc says the same instead of suggesting add-nucleosomes. README and --help state the input requirement. Not reframed: MM/ML cannot be recovered without the clipped bases. --- README.md | 7 + src/main.rs | 1 + src/subcommands/fire.rs | 8 +- src/subcommands/qc.rs | 18 +- src/utils/bio_io.rs | 13 +- src/utils/input_bam.rs | 2 + src/utils/ma_io.rs | 386 +++++++++++++++++++++++++++---- tests/data/ont_hardclip_mmml.bam | Bin 0 -> 19487 bytes tests/regression/convert_tags.rs | 49 +++- tests/regression/extract.rs | 39 ++-- tests/regression/fire.rs | 60 +++-- tests/regression/qc.rs | 14 ++ 12 files changed, 490 insertions(+), 107 deletions(-) create mode 100644 tests/data/ont_hardclip_mmml.bam diff --git a/README.md b/README.md index b8c8efbfc..7578fe250 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,13 @@ ft --help [Help page for fibertools](https://fiberseq.github.io/fibertools/help.html#ft) +## Input BAM requirements + +Fiber-seq tags (`MM`/`ML`, `ns`/`nl`/`as`/`al`, `Ma`) describe the full read. Aligners that hard-clip supplementary alignments copy those tags unchanged onto the clipped record, where they no longer match `SEQ`. `ft` drops the tags on such records and warns. To keep calls on supplementary alignments, align with soft clipping: + +- PacBio: `pbmm2 align` (it never hard-clips). +- ONT: `dorado aligner -Y ...`, or `samtools fastq -T '*' in.bam | minimap2 -Y -y -ax map-ont ref.fa -`. + # Highlighted subcommands for `fibertools-rs` ### `ft predict-m6a` diff --git a/src/main.rs b/src/main.rs index c62ffa0d4..331b87721 100644 --- a/src/main.rs +++ b/src/main.rs @@ -146,6 +146,7 @@ pub fn main() -> Result<(), Error> { } None => {} }; + utils::ma_io::report_stale_frames(); let duration = pg_start.elapsed(); log::info!( "{} done! Time elapsed: {}", diff --git a/src/subcommands/fire.rs b/src/subcommands/fire.rs index 202b7295f..8d991df38 100644 --- a/src/subcommands/fire.rs +++ b/src/subcommands/fire.rs @@ -30,7 +30,13 @@ pub fn add_fire_to_rec( // and their paired precisions, keeping only entries with p > 0. let (fire_starts, fire_lens, fire_quals): (Vec, Vec, Vec) = { let Some(msp) = rec.annotations.get_type(ma_io::MSP_TYPE) else { - log::warn!("FIRE: no msp annotations on record; skipping"); + if ma_io::record_frame_reason(&rec.record).is_some() { + // The reader dropped this record's annotations: write it as an + // honest untagged read instead of passing the stale tags on. + rec.serialize_annotations(); + } else { + log::debug!("FIRE: no msp annotations on record; skipping"); + } return; }; if msp.annotations.len() != precisions.len() { diff --git a/src/subcommands/qc.rs b/src/subcommands/qc.rs index 8b90914ee..16156682c 100644 --- a/src/subcommands/qc.rs +++ b/src/subcommands/qc.rs @@ -183,10 +183,7 @@ impl<'a> QcStats<'a> { }; bump(&mut self.fiberseq_callable, state_key, 1, f.inc()); if state == fiber::CallableState::Untagged - && crate::utils::ma_io::read_length_is_stale( - fiber.annotations.read_length, - fiber.record.seq_len(), - ) + && crate::utils::ma_io::record_frame_reason(&fiber.record).is_some() { self.stale_tags += 1; } @@ -540,16 +537,17 @@ pub fn run_qc(opts: &mut QcOpts) -> Result<(), anyhow::Error> { if untagged > 0 { log::warn!( "{untagged} reads have no fiberseq_callable state (no nuc/msp \ - calls, no SEQ to derive from, or a stale tag). These reads never enter \ - count_filtered. Run ft add-nucleosomes or ft predict-m6a to \ - call them." + calls, no SEQ to derive from, or tags from a hard-clipped alignment). \ + These reads never enter count_filtered. Reads that were never called: \ + run ft add-nucleosomes or ft predict-m6a." ); } if stats.stale_tags > 0 { log::warn!( - "{} reads carry a stale fiberseq_callable tag: the recorded read \ - length does not match the record. These reads count as Untagged.", - stats.stale_tags + "{} of them carry Fiber-seq tags from a longer read than their SEQ \ + (hard-clipped supplementary alignments); their calls were dropped. {}", + stats.stale_tags, + crate::utils::ma_io::HARD_CLIP_REMEDY ); } let mut out = bio_io::writer(&opts.out)?; diff --git a/src/utils/bio_io.rs b/src/utils/bio_io.rs index 875a36da2..c201da571 100644 --- a/src/utils/bio_io.rs +++ b/src/utils/bio_io.rs @@ -295,16 +295,9 @@ where for r in self.bam.by_ref().take(self.chunk_size) { pulled += 1; let r = r.unwrap(); - let has_mm_and_ml = r.aux(b"MM").is_ok() && r.aux(b"ML").is_ok(); - if has_mm_and_ml - && (r.cigar().leading_hardclips() > 0 || r.cigar().trailing_hardclips() > 0) - { - log::warn!( - "Skipping read ({}) because it has been hard clipped and has ML and MM tags. This read will be excluded from calculations and any output.", - String::from_utf8_lossy(r.qname()) - ); - continue; - } + // Hard-clipped records with stale tags are not skipped here: + // ma_io::read_record clears their annotations and the + // writers strip the tags, so every command sees one policy. // filter by bit flag if r.flags() & self.bit_flag_filter != 0 { continue; diff --git a/src/utils/input_bam.rs b/src/utils/input_bam.rs index 302a6996f..882c567ed 100644 --- a/src/utils/input_bam.rs +++ b/src/utils/input_bam.rs @@ -272,6 +272,8 @@ impl FiberFilters { #[derive(Debug, Args)] pub struct InputBam { /// Input BAM file. If no path is provided stdin is used. For m6A prediction, this should be a HiFi bam file with kinetics data. For other commands, this should be a bam file with m6A calls. + /// + /// Fiber-seq tags describe the full read, so aligned input must be soft-clipped: pbmm2 never hard-clips; for ONT use `dorado aligner -Y` or `minimap2 -Y -y`. Tags on hard-clipped records are dropped with a warning. #[clap(default_value = "-", value_hint = ValueHint::AnyPath)] pub bam: String, #[clap(flatten)] diff --git a/src/utils/ma_io.rs b/src/utils/ma_io.rs index 730de5552..244d38837 100644 --- a/src/utils/ma_io.rs +++ b/src/utils/ma_io.rs @@ -53,6 +53,17 @@ type MspInput<'a> = (&'a [u32], &'a [u32], Option<&'a [u8]>); /// Tolerant of malformed MM/ML — the library handles those internally /// without panicking. pub fn read_record(record: &bam::Record) -> Result { + // A record whose tags describe another read is untagged, and parsing + // its MM/ML would only produce truncation noise: decide before parsing. + if let Some(why) = record_frame_reason(record) { + let mut annot = MolecularAnnotations::new(0); + annot.set_aligned_blocks_raw( + molecular_annotation::AlignedBlocks::from_record(record), + record.is_reverse(), + ); + drop_stale_frame(&mut annot, record, why); + return Ok(annot); + } let mut annot = MolecularAnnotations::from_record(record); // If MA tag is absent, also ingest legacy nuc/msp tags. The library // already populates basemod types (m6a/cpg) from MM/ML, so we merge @@ -78,58 +89,103 @@ pub fn read_record(record: &bam::Record) -> Result { merge_missing_types(&mut annot, read_legacy_fibertig(record)?); } } - drop_stale_frame(&mut annot, record); + // Backstop on the parsed model: a read length that disagrees with SEQ, + // or any annotation ending past it. + if let Some(why) = model_frame_reason(&annot, record) { + drop_stale_frame(&mut annot, record, why); + } Ok(annot) } /// How many stale-frame records get a WARN before the rest drop to DEBUG. -/// ONT BAMs can hold thousands of hard-clipped supplementary reads. +/// ONT BAMs can hold thousands of hard-clipped supplementary reads. A total +/// is printed at exit by [`report_stale_frames`]. const STALE_FRAME_WARN_LIMIT: usize = 10; -/// Treat a record whose tags come from another frame as untagged (#136). +/// Records whose annotations were dropped because their tags did not fit SEQ. +static STALE_FRAMES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// What to tell a user who hit a stale frame. Aligners that hard-clip +/// supplementary alignments (minimap2 and dorado aligner without -Y) copy the +/// full-length read's tags onto the clipped record; the tags cannot be +/// recovered, only avoided. +pub const HARD_CLIP_REMEDY: &str = + "Realign with soft clipping: pbmm2 align (PacBio, its default), \ + dorado aligner -Y, or minimap2 -Y -y. Or drop supplementary alignments with -F 2048."; + +/// Treat a record whose tags come from another frame as untagged (#136, #31). /// Hard-clipped supplementary alignments keep the full-length read's tags, /// which would index past SEQ: consumers panic, or emit misplaced and /// u32-wrapped coordinates. Lives here so every path that parses a record -/// (the fiber reader, convert-tags, strip-basemods, ddda-to-m6a, predict-m6a) -/// sees the same thing. -fn drop_stale_frame(annot: &mut MolecularAnnotations, record: &bam::Record) { - use std::sync::atomic::{AtomicUsize, Ordering}; - static SEEN: AtomicUsize = AtomicUsize::new(0); - let Some(why) = stale_frame_reason(annot, record) else { - return; - }; - let n = SEEN.fetch_add(1, Ordering::Relaxed); +/// (the fiber reader, convert-tags, strip-basemods, ddda-to-m6a, predict-m6a, +/// fibertig) sees the same thing, and [`write_record`] strips the stale tags +/// on the way out so nothing downstream trusts them. +/// +/// Not reframed on purpose: nuc/msp could be shifted by the hard clip, but +/// MM/ML cannot be recovered without the clipped bases, and half a record +/// (nucleosomes, no m6A) is worse than an honest untagged one. The spec's +/// answer is soft clipping (`HARD_CLIP_REMEDY`). +fn drop_stale_frame(annot: &mut MolecularAnnotations, record: &bam::Record, why: String) { + use std::sync::atomic::Ordering; + let n = STALE_FRAMES.fetch_add(1, Ordering::Relaxed); let qname = String::from_utf8_lossy(record.qname()); - if n < STALE_FRAME_WARN_LIMIT { + if n == 0 { log::warn!( - "dropping annotations for {qname}: {why} (hard-clipped supplementary alignment?)" + "some records carry Fiber-seq tags that describe the full-length read, not their SEQ \ + (hard-clipped supplementary alignments). Their annotations are dropped and they are \ + written as untagged reads. {HARD_CLIP_REMEDY}" ); + } + if n < STALE_FRAME_WARN_LIMIT { + log::warn!("dropping annotations for {qname}: {why}"); if n + 1 == STALE_FRAME_WARN_LIMIT { - log::warn!("further stale-frame records are logged at debug level"); + log::warn!( + "further such records are logged at debug level; a total is printed at exit" + ); } } else { log::debug!("dropping annotations for {qname}: {why}"); } annot.annotation_types.clear(); + // An untagged read's frame is its SEQ, or its CIGAR query span without SEQ, + // so the writers persist a clean `Ma:Z:` instead of the stale length. + let seq_len = record.seq_len(); + annot.read_length = if seq_len > 0 { + seq_len as u32 + } else { + cigar_query_len(record) + }; } -/// Make the fiberseq_callable annotation agree with the CLI minimums. -/// Runs right after parsing, before any consumer-side pruning. Derives -/// when the tag is absent (backfill) or the minimums are non-default -/// (recalculation); otherwise the on-disk tag stands. -pub fn sync_fiberseq_callable( - annot: &mut MolecularAnnotations, - record: &bam::Record, - filters: &crate::utils::input_bam::FiberFilters, -) { - let needed = - filters.callable_minimums_are_custom() || annot.get_type(FIBERSEQ_CALLABLE_TYPE).is_none(); - if needed && can_derive_callable(annot, record) { - let (min_msp, min_ave) = filters.callable_minimums(); - derive_fiberseq_callable(annot, min_msp, min_ave); +/// Log the number of records whose annotations were dropped for a stale +/// frame. Called once at exit by `main`. +pub fn report_stale_frames() { + let n = STALE_FRAMES.load(std::sync::atomic::Ordering::Relaxed); + if n > 0 { + log::warn!( + "dropped annotations on {n} records whose Fiber-seq tags did not match their SEQ \ + (hard-clipped supplementary alignments). {HARD_CLIP_REMEDY}" + ); } } +/// Query bases the CIGAR consumes, hard clips excluded. +fn cigar_query_len(record: &bam::Record) -> u32 { + use rust_htslib::bam::record::Cigar; + record + .cigar() + .iter() + .map(|c| match c { + Cigar::Match(l) + | Cigar::Ins(l) + | Cigar::SoftClip(l) + | Cigar::Equal(l) + | Cigar::Diff(l) => *l, + _ => 0, + }) + .sum() +} + /// True when a present SEQ disagrees with the recorded read length /// (something rewrote the read after tagging). SEQ-less records are never /// stale: the MA read length is the frame. @@ -137,15 +193,73 @@ pub(crate) fn read_length_is_stale(read_length: u32, seq_len: usize) -> bool { seq_len > 0 && read_length as usize != seq_len } -/// Why a record's annotations do not fit its SEQ, or `None` when they do. -/// Hard-clipped supplementary alignments keep the full-length read's tags -/// (MA or legacy), so coordinates run past the clipped SEQ and, once flipped -/// for a reverse strand, wrap below zero (#136). SEQ-less records are never -/// stale: the MA read length is the frame. -pub(crate) fn stale_frame_reason( - annot: &MolecularAnnotations, - record: &bam::Record, -) -> Option { +/// Signals, read straight off the record, that its Fiber-seq tags describe a +/// different read than its SEQ. Each tag family has its own frame signal: +/// - MA: the tag's own read length. A hard-clipped record whose tags were +/// computed after clipping has `read_length == seq_len` and is fine. +/// - legacy `ns`/`nl`/`as`/`al` and `fs`/`fl`: no frame is recorded and no +/// producer writes them after clipping, so any hard clip means stale; so +/// does a missing SEQ, since nothing then anchors them. +/// - MM/ML: the SAM `MN` tag when present (the spec's frame for exactly this +/// case), else hard clips. +/// +/// SEQ-less MA records are never stale: the MA read length is the frame. +/// Both the reader ([`read_record`]) and the writer ([`write_record`]) use +/// this, so a stale record is cleared on the way in and cleaned on the way +/// out. +pub(crate) fn record_frame_reason(record: &bam::Record) -> Option { + let seq_len = record.seq_len(); + let cigar = record.cigar(); + let hard_clipped = cigar.leading_hardclips() > 0 || cigar.trailing_hardclips() > 0; + if let Some((ma, _, _)) = ma_family_tags(record) { + if let Some(read_length) = ma.split(';').next().and_then(|s| s.parse::().ok()) { + if seq_len > 0 && read_length != seq_len { + return Some(format!( + "MA read length {read_length} does not match the {seq_len} bp sequence" + )); + } + } + } else if has_legacy_nuc_msp(record) || has_legacy_fibertig(record) { + if hard_clipped { + return Some("legacy nuc/msp tags on a hard-clipped alignment".to_string()); + } + if seq_len == 0 { + return Some("legacy nuc/msp tags on a record without SEQ".to_string()); + } + } + if matches!(record.aux(b"MM"), Ok(Aux::String(_))) { + match record.aux(b"MN").ok().and_then(aux_as_usize) { + Some(mn) => { + if seq_len > 0 && mn != seq_len { + return Some(format!("MN {mn} does not match the {seq_len} bp sequence")); + } + } + None => { + if hard_clipped { + return Some("MM/ML on a hard-clipped alignment without an MN tag".to_string()); + } + } + } + } + None +} + +fn aux_as_usize(aux: Aux) -> Option { + match aux { + Aux::I8(v) => usize::try_from(v).ok(), + Aux::U8(v) => Some(v as usize), + Aux::I16(v) => usize::try_from(v).ok(), + Aux::U16(v) => Some(v as usize), + Aux::I32(v) => usize::try_from(v).ok(), + Aux::U32(v) => Some(v as usize), + _ => None, + } +} + +/// The parsed model disagrees with SEQ: a read length that does not match, +/// or an annotation ending past it. Backstop behind [`record_frame_reason`] +/// for tags that carry no frame signal of their own. +fn model_frame_reason(annot: &MolecularAnnotations, record: &bam::Record) -> Option { let seq_len = record.seq_len(); if seq_len == 0 { return None; @@ -172,6 +286,33 @@ pub(crate) fn stale_frame_reason( }) } +/// Why a record's annotations do not fit its SEQ, or `None` when they do: +/// the record-level signals first, then the parsed model as a backstop. +#[cfg(test)] +pub(crate) fn stale_frame_reason( + annot: &MolecularAnnotations, + record: &bam::Record, +) -> Option { + record_frame_reason(record).or_else(|| model_frame_reason(annot, record)) +} + +/// Make the fiberseq_callable annotation agree with the CLI minimums. +/// Runs right after parsing, before any consumer-side pruning. Derives +/// when the tag is absent (backfill) or the minimums are non-default +/// (recalculation); otherwise the on-disk tag stands. +pub fn sync_fiberseq_callable( + annot: &mut MolecularAnnotations, + record: &bam::Record, + filters: &crate::utils::input_bam::FiberFilters, +) { + let needed = + filters.callable_minimums_are_custom() || annot.get_type(FIBERSEQ_CALLABLE_TYPE).is_none(); + if needed && can_derive_callable(annot, record) { + let (min_msp, min_ave) = filters.callable_minimums(); + derive_fiberseq_callable(annot, min_msp, min_ave); + } +} + /// True when the callable state can be derived: calling ran (nuc or msp /// present) and the frame is not stale. Derivation is pure MA-tag /// arithmetic, so SEQ-less records derive fine. @@ -305,10 +446,29 @@ fn merge_missing_types(dst: &mut MolecularAnnotations, src: MolecularAnnotations /// Producers that create or modify base mods must instead call /// [`write_record_with_basemods`], which canonically re-emits MM/ML. pub fn write_record(record: &mut bam::Record, annot: &MolecularAnnotations) { - strip_consumed_legacy_tags(record); + if record_frame_reason(record).is_some() { + // The reader cleared this record's annotations (drop_stale_frame); + // leave no stale tag behind for another tool to trust. + strip_all_fiber_tags(record); + } else { + strip_consumed_legacy_tags(record); + } annot.to_record(record); } +/// Every Fiber-seq tag fibertools knows how to read: legacy nuc/msp/fibertig +/// arrays, MM/ML/MN base mods, and both spellings of the MA family. Used only +/// for records whose frame is stale (`record_frame_reason`), where none of +/// them describe this SEQ. +fn strip_all_fiber_tags(record: &mut bam::Record) { + for tag in [ + b"ns", b"nl", b"as", b"al", b"aq", b"fs", b"fl", b"fa", b"MM", b"ML", b"MN", b"Ma", b"Aq", + b"An", b"MA", b"AQ", b"AN", + ] { + record.remove_aux(tag).ok(); + } +} + /// Provenance rule shared by every MA write: strip exactly the legacy tags /// that [`read_record`] consumed as the source of the annotation model being /// written — they are superseded by the MA-family tags (v0.9 replace @@ -1049,4 +1209,152 @@ mod tests { assert_eq!(msp.annotations.len(), 1); assert_eq!(msp.annotations[0].start, 200, "read back stale MA tag"); } + + /// A mapped synthetic record with the given SEQ, CIGAR and flags. + fn synth_aligned(seq: &[u8], cigar: &str, flags: u16) -> bam::Record { + use rust_htslib::bam::record::CigarString; + let mut record = bam::Record::new(); + let qual = vec![60u8; seq.len()]; + let cigar = CigarString::try_from(cigar).expect("cigar parses"); + record.set(b"frame_test", Some(&cigar), seq, &qual); + record.set_flags(flags); + record.set_tid(0); + record.set_pos(0); + record + } + + fn legacy(record: &mut bam::Record, starts: &[u32], lens: &[u32]) { + record + .push_aux(b"ns", Aux::ArrayU32(starts.into())) + .unwrap(); + record.push_aux(b"nl", Aux::ArrayU32(lens.into())).unwrap(); + } + + fn nuc_starts(record: &bam::Record) -> Vec { + let annot = read_record(record).expect("read_record"); + annot + .get_type(NUC_TYPE) + .map(|t| t.annotations.iter().map(|a| a.start).collect()) + .unwrap_or_default() + } + + // MA carries its own frame: a mismatch is stale on either strand, a + // match is fine even with hard clips (tags computed after clipping). + #[test] + fn stale_frame_ma_read_length() { + let seq = b"ACGT".repeat(50); // 200 bp + for flags in [0u16, 16] { + let mut r = synth_aligned(&seq, "200M", flags); + r.push_aux(b"Ma", Aux::String("300;nuc.:10-40")).unwrap(); + assert!(record_frame_reason(&r).is_some(), "flags {flags}"); + assert!(nuc_starts(&r).is_empty()); + assert_eq!( + read_record(&r).unwrap().read_length, + 200, + "frame reset to SEQ" + ); + } + let mut r = synth_aligned(&seq, "50H200M", 2048); + r.push_aux(b"Ma", Aux::String("200;nuc.:10-40")).unwrap(); + assert!(record_frame_reason(&r).is_none()); + assert_eq!(nuc_starts(&r), vec![9], "MA text is 1-based"); + } + + // Legacy tags carry no frame: any hard clip is stale even when every + // coordinate fits, a soft clip is not, and no SEQ is. + #[test] + fn stale_frame_legacy_uses_hard_clips() { + let seq = b"ACGT".repeat(50); + let mut fits = synth_aligned(&seq, "30H200M", 2048); + legacy(&mut fits, &[10, 100], &[20, 20]); + assert!(record_frame_reason(&fits).is_some()); + assert!(nuc_starts(&fits).is_empty()); + + let mut soft = synth_aligned(&seq, "30S170M", 2048); + legacy(&mut soft, &[10, 100], &[20, 20]); + assert!(record_frame_reason(&soft).is_none()); + assert_eq!(nuc_starts(&soft), vec![10, 100]); + + let mut exact = synth_aligned(&seq, "200M", 0); + legacy(&mut exact, &[180], &[20]); + assert!(stale_frame_reason(&read_record(&exact).unwrap(), &exact).is_none()); + assert_eq!(nuc_starts(&exact), vec![180]); + + let mut past = synth_aligned(&seq, "200M", 0); + legacy(&mut past, &[180], &[21]); + assert!(nuc_starts(&past).is_empty()); + + let mut seqless = synth_aligned(b"", "200M", 256); + legacy(&mut seqless, &[10], &[20]); + assert!(record_frame_reason(&seqless).is_some()); + let annot = read_record(&seqless).unwrap(); + assert!(annot.annotation_types.is_empty()); + assert_eq!( + annot.read_length, 200, + "frame from the CIGAR when SEQ is absent" + ); + } + + // MM/ML: MN is the frame when present, hard clips otherwise. + #[test] + fn stale_frame_mm_ml_uses_mn_then_hard_clips() { + let seq = b"ACGT".repeat(50); + let mm = |r: &mut bam::Record| { + r.push_aux(b"MM", Aux::String("A+a.,0;")).unwrap(); + r.push_aux(b"ML", Aux::ArrayU8((&[200u8][..]).into())) + .unwrap(); + }; + let mut mn_mismatch = synth_aligned(&seq, "200M", 0); + mm(&mut mn_mismatch); + mn_mismatch.push_aux(b"MN", Aux::I32(500)).unwrap(); + assert!(record_frame_reason(&mn_mismatch).is_some()); + assert!(read_record(&mn_mismatch) + .unwrap() + .annotation_types + .is_empty()); + + let mut mn_ok_clipped = synth_aligned(&seq, "50H200M", 2048); + mm(&mut mn_ok_clipped); + mn_ok_clipped.push_aux(b"MN", Aux::I32(200)).unwrap(); + assert!(record_frame_reason(&mn_ok_clipped).is_none()); + assert!(!read_record(&mn_ok_clipped) + .unwrap() + .annotation_types + .is_empty()); + + let mut no_mn_clipped = synth_aligned(&seq, "50H200M", 2048); + mm(&mut no_mn_clipped); + assert!(record_frame_reason(&no_mn_clipped).is_some()); + + let mut no_mn_soft = synth_aligned(&seq, "50S150M", 2048); + mm(&mut no_mn_soft); + assert!(record_frame_reason(&no_mn_soft).is_none()); + } + + // A stale record leaves the writer as an honest untagged read: no legacy + // arrays, no MM/ML/MN, and an MA tag whose frame is SEQ. + #[test] + fn write_record_strips_every_tag_of_a_stale_record() { + let seq = b"ACGT".repeat(50); + let mut r = synth_aligned(&seq, "30H200M", 2048); + legacy(&mut r, &[10], &[20]); + r.push_aux(b"MM", Aux::String("A+a.,0;")).unwrap(); + r.push_aux(b"ML", Aux::ArrayU8((&[200u8][..]).into())) + .unwrap(); + let annot = read_record(&r).unwrap(); + assert!(annot.annotation_types.is_empty()); + write_record(&mut r, &annot); + for tag in [b"ns", b"nl", b"MM", b"ML"] { + assert!( + r.aux(tag).is_err(), + "{} survived", + String::from_utf8_lossy(tag) + ); + } + let ma = r.aux(b"Ma"); + assert!( + matches!(ma, Ok(Aux::String(s)) if s.split(';').next() == Some("200")), + "Ma = {ma:?}" + ); + } } diff --git a/tests/data/ont_hardclip_mmml.bam b/tests/data/ont_hardclip_mmml.bam new file mode 100644 index 0000000000000000000000000000000000000000..4a36cc362e9a3de088a29dc7607d8f093f442c11 GIT binary patch literal 19487 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 literal 0 HcmV?d00001 diff --git a/tests/regression/convert_tags.rs b/tests/regression/convert_tags.rs index 7d94996ef..b2350300a 100644 --- a/tests/regression/convert_tags.rs +++ b/tests/regression/convert_tags.rs @@ -173,23 +173,48 @@ fn convert_tags_migrates_uppercase_to_canonical() { } } -// Hard-clipped supplementary reads keep the full-length read's legacy tags, -// which run past SEQ (#136). read_record drops those annotations, so -// convert-tags must not serialize the overrunning coordinates into an MA -// tag. The two primaries in the fixture convert normally. -#[test] -fn convert_tags_drops_annotations_that_exceed_the_sequence() { +// Hard-clipped supplementary reads keep the full-length read's tags, which do +// not match SEQ (#136). read_record drops those annotations and write_record +// strips every stale tag, so convert-tags writes an honest untagged record: an +// MA tag with no sections and no legacy arrays or MM/ML/MN. Primaries convert +// normally and keep their MM/ML. +fn assert_stale_records_cleaned(bam: &str, n_supp: usize) { let out = NamedTempFile::with_suffix(".bam").unwrap(); - convert(&fixture("ont_hardclip_supplementary.bam"), out.path()); - let mut n_supp = 0; + convert(&fixture(bam), out.path()); + let mut seen_supp = 0; for rec in records(out.path()) { - let tag = ma(&rec).unwrap_or_default(); + let tag = ma(&rec).expect("every record gets an MA tag"); // MA is ";": no sections means no annotations let has_annotations = tag.trim_end_matches(';').contains(';'); - assert_eq!(!has_annotations, rec.is_supplementary(), "Ma tag {tag:?}"); + assert_eq!(!has_annotations, rec.is_supplementary(), "{bam} Ma {tag:?}"); + assert_eq!( + tag.split(';').next().unwrap(), + rec.seq_len().to_string(), + "{bam}: MA frame must be SEQ" + ); + for legacy in LEGACY_TAGS { + assert!(rec.aux(legacy).is_err(), "{bam}: legacy tag survived"); + } + let has_mm = rec.aux(b"MM").is_ok(); + assert_eq!( + has_mm, + !rec.is_supplementary(), + "{bam}: MM/ML on a stale record" + ); if rec.is_supplementary() { - n_supp += 1; + assert!(rec.aux(b"MN").is_err(), "{bam}: MN on a stale record"); + seen_supp += 1; } } - assert_eq!(n_supp, 2); + assert_eq!(seen_supp, n_supp, "{bam}"); +} + +#[test] +fn convert_tags_cleans_hard_clipped_legacy_records() { + assert_stale_records_cleaned("ont_hardclip_supplementary.bam", 2); +} + +#[test] +fn convert_tags_cleans_hard_clipped_mm_ml_records() { + assert_stale_records_cleaned("ont_hardclip_mmml.bam", 1); } diff --git a/tests/regression/extract.rs b/tests/regression/extract.rs index d029a3ee4..52d8a84ee 100644 --- a/tests/regression/extract.rs +++ b/tests/regression/extract.rs @@ -105,15 +105,10 @@ fn extract_reads_ma_spelled_fixture() { // The reader drops annotations that do not fit SEQ (hard-clipped supplementary // reads keep the full-length read's tags, #136). Before this, extract printed // misplaced coordinates for the forward read and u32-wrapped ones for the -// reverse read. -#[test] -fn extract_drops_annotations_that_exceed_the_sequence() { - let out = run(&[ - "extract", - "--all", - "-", - fixture("ont_hardclip_supplementary.bam").to_str().unwrap(), - ]); +// reverse read. Supplementaries must report `.` for nuc, msp and m6a; the +// primaries must not. +fn assert_supplementaries_untagged(bam: &str, n_primary: usize, n_supp: usize) { + let out = run(&["extract", "--all", "-", fixture(bam).to_str().unwrap()]); let mut lines = out.lines(); let header: Vec<&str> = lines.next().unwrap().split('\t').collect(); let col = |name: &str| header.iter().position(|h| *h == name).unwrap(); @@ -123,19 +118,33 @@ fn extract_drops_annotations_that_exceed_the_sequence() { col("msp_starts"), col("m6a"), ); - let mut n_primary = 0; - let mut n_supp = 0; + let (mut seen_primary, mut seen_supp) = (0, 0); for line in lines { let f: Vec<&str> = line.split('\t').collect(); let supplementary = f[flag].parse::().unwrap() & 2048 != 0; for c in [nuc, msp, m6a] { - assert_eq!(f[c] == ".", supplementary, "{}: {}", header[c], f[c]); + assert_eq!(f[c] == ".", supplementary, "{bam} {}: {}", header[c], f[c]); } if supplementary { - n_supp += 1; + seen_supp += 1; } else { - n_primary += 1; + seen_primary += 1; } } - assert_eq!((n_primary, n_supp), (2, 2)); + assert_eq!((seen_primary, seen_supp), (n_primary, n_supp), "{bam}"); +} + +// Legacy ns/nl/as/al tags, no MM/ML (dorado aligner strips them): caught by +// the hard-clip rule for legacy tags. +#[test] +fn extract_drops_legacy_annotations_on_hard_clipped_reads() { + assert_supplementaries_untagged("ont_hardclip_supplementary.bam", 2, 2); +} + +// MM/ML/MN copied verbatim onto a 2376H hard-clipped supplementary (the +// plain minimap2 shape): caught by MN != SEQ length. Until 0.14 this record +// was silently removed from every output instead. +#[test] +fn extract_drops_mm_ml_on_hard_clipped_reads() { + assert_supplementaries_untagged("ont_hardclip_mmml.bam", 1, 1); } diff --git a/tests/regression/fire.rs b/tests/regression/fire.rs index 2c87ffa42..38c6b496f 100644 --- a/tests/regression/fire.rs +++ b/tests/regression/fire.rs @@ -27,41 +27,61 @@ fn fire_on_legacy_input_strips_consumed_legacy_tags() { } } -// Hard-clipped supplementary alignments can keep nuc/msp tag coordinates -// from the full-length read, so positions run past the clipped SEQ (and wrap -// below zero when flipped on reverse-strand records). The reader drops those -// annotations, so `ft fire` has nothing to score and writes the records to -// the output unchanged instead of panicking (#136). The fixture holds two -// scorable primary reads plus a forward and a reverse hard-clipped -// supplementary read from TEnCATS ONT data. -#[test] -fn fire_skips_records_whose_coords_exceed_the_sequence() { +// Hard-clipped supplementary alignments keep the full-length read's tags +// (#136). The reader drops those annotations, so `ft fire` has nothing to +// score; it writes the record as an untagged read (MA tag with no sections, +// stale legacy arrays and MM/ML stripped) instead of panicking or passing the +// stale tags on. The fixtures hold scorable primary reads plus hard-clipped +// supplementaries from TEnCATS ONT data: one with legacy tags only, one with +// MM/ML/MN copied verbatim. +fn assert_fire_cleans_stale_records(bam: &str, n_scored: usize, n_cleaned: usize) { let scored = NamedTempFile::with_suffix(".bam").unwrap(); run(&[ "fire", "--ont", - fixture("ont_hardclip_supplementary.bam").to_str().unwrap(), + fixture(bam).to_str().unwrap(), scored.path().to_str().unwrap(), ]); let mut reader = bam::Reader::from_path(scored.path()).unwrap(); - let mut n_scored = 0; - let mut n_skipped = 0; + let (mut seen_scored, mut seen_cleaned) = (0, 0); for rec in reader.records() { let rec = rec.unwrap(); + let ma = match rec.aux(b"Ma") { + Ok(bam::record::Aux::String(s)) => s.to_string(), + _ => panic!("{bam}: record without Ma tag"), + }; if rec.is_supplementary() { - assert!(rec.aux(b"Ma").is_err(), "unscorable record got a Ma tag"); assert!( - rec.aux(b"as").is_ok(), - "skipped record lost its original tags" + !ma.trim_end_matches(';').contains(';'), + "{bam}: stale record kept annotations: {ma}" ); - n_skipped += 1; + for tag in [b"as", b"ns", b"MM", b"ML", b"MN"] { + assert!( + rec.aux(tag).is_err(), + "{bam}: stale {} survived", + String::from_utf8_lossy(tag) + ); + } + seen_cleaned += 1; } else { - assert!(rec.aux(b"Ma").is_ok(), "scorable record missing Ma tag"); - n_scored += 1; + assert!( + ma.contains("msp"), + "{bam}: scorable record missing msp in {ma}" + ); + seen_scored += 1; } } - assert_eq!(n_scored, 2); - assert_eq!(n_skipped, 2); + assert_eq!((seen_scored, seen_cleaned), (n_scored, n_cleaned), "{bam}"); +} + +#[test] +fn fire_cleans_hard_clipped_legacy_records() { + assert_fire_cleans_stale_records("ont_hardclip_supplementary.bam", 2, 2); +} + +#[test] +fn fire_cleans_hard_clipped_mm_ml_records() { + assert_fire_cleans_stale_records("ont_hardclip_mmml.bam", 1, 1); } fn extract_fdrs(out: &str) -> Vec { diff --git a/tests/regression/qc.rs b/tests/regression/qc.rs index 856375cdd..d4069ed9e 100644 --- a/tests/regression/qc.rs +++ b/tests/regression/qc.rs @@ -326,3 +326,17 @@ fn qc_custom_minimums_reach_state_rows() { let filt: i64 = rows_for(&out, "phased_reads").iter().map(|r| r.2).sum(); assert_eq!(filt, 0, "filtered column agrees with the statet rows"); } + +// Records whose tags did not match SEQ count as Untagged in ft qc (#136). +#[test] +fn qc_counts_hard_clipped_records_as_untagged() { + let out = run(&[ + "qc", + fixture("ont_hardclip_supplementary.bam").to_str().unwrap(), + ]); + let row = out + .lines() + .find(|l| l.starts_with("fiberseq_callable\tUntagged\t")) + .expect("Untagged row"); + assert_eq!(row.split('\t').nth(2), Some("2"), "{row}"); +} From b9d6a0e6d15335e00dbadb336c601b24479ad55f Mon Sep 17 00:00:00 2001 From: "Mitchell R. Vollger" Date: Fri, 18 Sep 2026 11:07:22 -0600 Subject: [PATCH 4/6] fix: act on the review of the stale-frame check - A matching MA read length now vouches for the MM/ML next to it, so fibertools' own hard-clipped output (ddda-to-m6a, predict-m6a) is no longer rejected for lacking MN. - write_record_with_basemods writes MN, the SAM spec's frame for MM/ML. - ft fire always writes the model back for records it cannot score, so a record dropped by the parsed-model backstop leaves without stale tags. - The dorado remedy is spelled `dorado aligner --mm2-opts "-Y"`. - SEQ-less MM/ML records count as stale instead of spamming parser warnings. - One warning carries the explanation and the first record; the qc warning no longer repeats the remedy; "1 record" is singular. - The MM/ML fixture's supplementary carries only MM/ML/MN, so the MN branch is exercised end to end. --- README.md | 2 +- src/subcommands/fire.rs | 12 +++-- src/subcommands/qc.rs | 6 +-- src/utils/input_bam.rs | 2 +- src/utils/ma_io.rs | 74 ++++++++++++++++++++++++++++--- tests/data/ont_hardclip_mmml.bam | Bin 19487 -> 19198 bytes tests/regression/extract.rs | 2 +- 7 files changed, 78 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7578fe250..45e4bc41e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ft --help Fiber-seq tags (`MM`/`ML`, `ns`/`nl`/`as`/`al`, `Ma`) describe the full read. Aligners that hard-clip supplementary alignments copy those tags unchanged onto the clipped record, where they no longer match `SEQ`. `ft` drops the tags on such records and warns. To keep calls on supplementary alignments, align with soft clipping: - PacBio: `pbmm2 align` (it never hard-clips). -- ONT: `dorado aligner -Y ...`, or `samtools fastq -T '*' in.bam | minimap2 -Y -y -ax map-ont ref.fa -`. +- ONT: `dorado aligner --mm2-opts "-Y" ...`, or `samtools fastq -T '*' in.bam | minimap2 -Y -y -ax map-ont ref.fa -`. minimap2 also writes SEQ-less secondary alignments by default; their tags are dropped too, so add `--secondary=no` or filter with `-F 256`. # Highlighted subcommands for `fibertools-rs` diff --git a/src/subcommands/fire.rs b/src/subcommands/fire.rs index 8d991df38..5e3c46710 100644 --- a/src/subcommands/fire.rs +++ b/src/subcommands/fire.rs @@ -30,13 +30,11 @@ pub fn add_fire_to_rec( // and their paired precisions, keeping only entries with p > 0. let (fire_starts, fire_lens, fire_quals): (Vec, Vec, Vec) = { let Some(msp) = rec.annotations.get_type(ma_io::MSP_TYPE) else { - if ma_io::record_frame_reason(&rec.record).is_some() { - // The reader dropped this record's annotations: write it as an - // honest untagged read instead of passing the stale tags on. - rec.serialize_annotations(); - } else { - log::debug!("FIRE: no msp annotations on record; skipping"); - } + // Nothing to score. Still write the model back so a record whose + // stale tags were dropped by the reader leaves as an honest + // untagged read instead of passing those tags on. + log::debug!("FIRE: no msp annotations on record; writing it unscored"); + rec.serialize_annotations(); return; }; if msp.annotations.len() != precisions.len() { diff --git a/src/subcommands/qc.rs b/src/subcommands/qc.rs index 16156682c..5e04e6d3a 100644 --- a/src/subcommands/qc.rs +++ b/src/subcommands/qc.rs @@ -545,9 +545,9 @@ pub fn run_qc(opts: &mut QcOpts) -> Result<(), anyhow::Error> { if stats.stale_tags > 0 { log::warn!( "{} of them carry Fiber-seq tags from a longer read than their SEQ \ - (hard-clipped supplementary alignments); their calls were dropped. {}", - stats.stale_tags, - crate::utils::ma_io::HARD_CLIP_REMEDY + (hard-clipped supplementary alignments); their calls were dropped. \ + See the warning above for how to realign.", + stats.stale_tags ); } let mut out = bio_io::writer(&opts.out)?; diff --git a/src/utils/input_bam.rs b/src/utils/input_bam.rs index 882c567ed..9cb38a1a5 100644 --- a/src/utils/input_bam.rs +++ b/src/utils/input_bam.rs @@ -273,7 +273,7 @@ impl FiberFilters { pub struct InputBam { /// Input BAM file. If no path is provided stdin is used. For m6A prediction, this should be a HiFi bam file with kinetics data. For other commands, this should be a bam file with m6A calls. /// - /// Fiber-seq tags describe the full read, so aligned input must be soft-clipped: pbmm2 never hard-clips; for ONT use `dorado aligner -Y` or `minimap2 -Y -y`. Tags on hard-clipped records are dropped with a warning. + /// Fiber-seq tags describe the full read, so aligned input must be soft-clipped: pbmm2 never hard-clips; for ONT use `dorado aligner --mm2-opts "-Y"` or `minimap2 -Y -y`. Tags on hard-clipped records are dropped with a warning. #[clap(default_value = "-", value_hint = ValueHint::AnyPath)] pub bam: String, #[clap(flatten)] diff --git a/src/utils/ma_io.rs b/src/utils/ma_io.rs index 244d38837..ba5eb1c3f 100644 --- a/src/utils/ma_io.rs +++ b/src/utils/ma_io.rs @@ -109,9 +109,9 @@ static STALE_FRAMES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicU /// supplementary alignments (minimap2 and dorado aligner without -Y) copy the /// full-length read's tags onto the clipped record; the tags cannot be /// recovered, only avoided. -pub const HARD_CLIP_REMEDY: &str = - "Realign with soft clipping: pbmm2 align (PacBio, its default), \ - dorado aligner -Y, or minimap2 -Y -y. Or drop supplementary alignments with -F 2048."; +pub const HARD_CLIP_REMEDY: &str = "Realign with soft clipping: pbmm2 align (PacBio; it never \ + hard-clips), dorado aligner --mm2-opts \"-Y\", or minimap2 -Y -y. Or drop supplementary \ + alignments with -F 2048."; /// Treat a record whose tags come from another frame as untagged (#136, #31). /// Hard-clipped supplementary alignments keep the full-length read's tags, @@ -130,13 +130,15 @@ fn drop_stale_frame(annot: &mut MolecularAnnotations, record: &bam::Record, why: let n = STALE_FRAMES.fetch_add(1, Ordering::Relaxed); let qname = String::from_utf8_lossy(record.qname()); if n == 0 { + // One message, so the cause never prints after the symptom when + // several threads hit this at once. log::warn!( "some records carry Fiber-seq tags that describe the full-length read, not their SEQ \ (hard-clipped supplementary alignments). Their annotations are dropped and they are \ - written as untagged reads. {HARD_CLIP_REMEDY}" + treated as untagged reads. {HARD_CLIP_REMEDY}\n\ + dropping annotations for {qname}: {why}" ); - } - if n < STALE_FRAME_WARN_LIMIT { + } else if n < STALE_FRAME_WARN_LIMIT { log::warn!("dropping annotations for {qname}: {why}"); if n + 1 == STALE_FRAME_WARN_LIMIT { log::warn!( @@ -162,8 +164,9 @@ fn drop_stale_frame(annot: &mut MolecularAnnotations, record: &bam::Record, why: pub fn report_stale_frames() { let n = STALE_FRAMES.load(std::sync::atomic::Ordering::Relaxed); if n > 0 { + let records = if n == 1 { "record" } else { "records" }; log::warn!( - "dropped annotations on {n} records whose Fiber-seq tags did not match their SEQ \ + "dropped annotations on {n} {records} whose Fiber-seq tags did not match their SEQ \ (hard-clipped supplementary alignments). {HARD_CLIP_REMEDY}" ); } @@ -218,6 +221,10 @@ pub(crate) fn record_frame_reason(record: &bam::Record) -> Option { "MA read length {read_length} does not match the {seq_len} bp sequence" )); } + // The MA tag was written for this SEQ, so it vouches for the + // MM/ML next to it too: fibertools' own writers emit MA and + // MM/ML together, and a hard-clipped record it produced is fine. + return None; } } else if has_legacy_nuc_msp(record) || has_legacy_fibertig(record) { if hard_clipped { @@ -228,6 +235,11 @@ pub(crate) fn record_frame_reason(record: &bam::Record) -> Option { } } if matches!(record.aux(b"MM"), Ok(Aux::String(_))) { + if seq_len == 0 { + // Positions are implicit in SEQ; without it there is nothing to + // decode against (minimap2 -y writes SEQ-less secondaries). + return Some("MM/ML on a record without SEQ".to_string()); + } match record.aux(b"MN").ok().and_then(aux_as_usize) { Some(mn) => { if seq_len > 0 && mn != seq_len { @@ -555,6 +567,14 @@ fn has_legacy_fibertig(record: &bam::Record) -> bool { pub fn write_record_with_basemods(record: &mut bam::Record, annot: &MolecularAnnotations) { write_record(record, annot); annot.write_mm_ml(record); + // MN is the SAM spec's frame for MM/ML: the SEQ length they were written + // against. With it, any consumer (samtools, modkit, this reader) can tell + // when a later hard clip has made them stale. + record.remove_aux(b"MN").ok(); + let seq_len = record.seq_len(); + if seq_len > 0 && matches!(record.aux(b"MM"), Ok(Aux::String(_))) { + record.push_aux(b"MN", Aux::I32(seq_len as i32)).ok(); + } } /// Read annotations from a BAM record. @@ -1329,6 +1349,46 @@ mod tests { let mut no_mn_soft = synth_aligned(&seq, "50S150M", 2048); mm(&mut no_mn_soft); assert!(record_frame_reason(&no_mn_soft).is_none()); + + // An MA tag written for this SEQ vouches for the MM/ML next to it: + // that is the shape fibertools' own writers produce. + let mut ma_vouches = synth_aligned(&seq, "50H200M", 2048); + mm(&mut ma_vouches); + ma_vouches + .push_aux(b"Ma", Aux::String("200;nuc.:10-40")) + .unwrap(); + assert!(record_frame_reason(&ma_vouches).is_none()); + let annot = read_record(&ma_vouches).unwrap(); + assert!(annot.get_type(NUC_TYPE).is_some()); + assert!(annot.get_type(crate::utils::basemods::M6A_TYPE).is_some()); + + let mut seqless = synth_aligned(b"", "200M", 256); + mm(&mut seqless); + assert!(record_frame_reason(&seqless).is_some()); + } + + // fibertools' own base-mod writer records the frame (MN) so its output + // survives a later hard clip check, including on a hard-clipped record. + #[test] + fn write_record_with_basemods_writes_mn_and_reads_back() { + use crate::utils::basemods::{canonical_header, M6A_TYPE}; + let seq = b"ACGT".repeat(50); + let mut r = synth_aligned(&seq, "50H200M", 2048); + let mut annot = read_record(&r).unwrap(); + let qspec = "Q".parse::().unwrap(); + let header = canonical_header(M6A_TYPE, b'A').unwrap().to_string(); + annot + .add_annotation_type(M6A_TYPE, qspec, Encoding::mm_ml()) + .add(0, 1, Strand::Forward, vec![200], Some(header)); + write_record_with_basemods(&mut r, &annot); + assert!( + matches!(r.aux(b"MN"), Ok(Aux::I32(200))), + "MN = {:?}", + r.aux(b"MN") + ); + assert!(record_frame_reason(&r).is_none()); + let back = read_record(&r).unwrap(); + assert!(back.get_type(M6A_TYPE).is_some(), "m6a lost on re-read"); } // A stale record leaves the writer as an honest untagged read: no legacy diff --git a/tests/data/ont_hardclip_mmml.bam b/tests/data/ont_hardclip_mmml.bam index 4a36cc362e9a3de088a29dc7607d8f093f442c11..a9c3f12480b4dd75e25b265a2649b0595f63a025 100644 GIT binary patch literal 19198 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 R|8IJ6`fADl3a|b<{sW-O^_Bnt literal 19487 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 diff --git a/tests/regression/extract.rs b/tests/regression/extract.rs index 52d8a84ee..687eae7d8 100644 --- a/tests/regression/extract.rs +++ b/tests/regression/extract.rs @@ -143,7 +143,7 @@ fn extract_drops_legacy_annotations_on_hard_clipped_reads() { // MM/ML/MN copied verbatim onto a 2376H hard-clipped supplementary (the // plain minimap2 shape): caught by MN != SEQ length. Until 0.14 this record -// was silently removed from every output instead. +// was deleted from every output with a per-record warning instead. #[test] fn extract_drops_mm_ml_on_hard_clipped_reads() { assert_supplementaries_untagged("ont_hardclip_mmml.bam", 1, 1); From abed559588498d7afcb45a9ab3b2a5d584022100 Mon Sep 17 00:00:00 2001 From: "Mitchell R. Vollger" Date: Fri, 18 Sep 2026 13:08:47 -0600 Subject: [PATCH 5/6] feat!: keep full-read nuc/MSP/FIRE tags on hard-clipped alignments and lift them MA-family annotations are molecular coordinates of the full read, so on a hard-clipped supplementary alignment they are still right; only the liftover has to know that SEQ starts at the leading hard clip. MM/ML are SEQ-relative and cannot be recovered without the clipped bases. The frame is decided from the record: an MA read length equal to SEQ plus both hard clips (or legacy ns/nl/as/al on any hard clip) is the full-read frame. molecular-annotation's AlignedBlocks carries a query offset, set by from_record from that rule; lifts subtract it, SEQ-relative projections (ft center) subtract it too, and pyMA mirrors the rule. In that frame the reader keeps nuc/msp/fire under the full read length, never parses MM/ML, and the writer strips MM/ML/MN. Such reads have no m6A, so they are NotCallable: ft fire writes them back unscored, add-nucleosomes and footprint skip them, qc counts them, and pileup FIRE denominators exclude them under --callable-fibers. Tags that match neither SEQ nor the full read are still dropped as before. An MN tag that disagrees with SEQ next to a matching MA tag drops only the base mods. Full-frame records are warned about once, only when m6A is actually dropped, with a total at exit; a second run over ft's own output is silent. extract --all reports fiber_length in the annotation frame. --- README.md | 2 +- molecular-annotation/CHANGELOG.md | 4 + .../molecular_annotation/pysam_utils.py | 55 +- molecular-annotation/python/src/lib.rs | 23 +- .../python/tests/test_molecular_annotation.py | 83 ++ molecular-annotation/src/coords.rs | 26 +- molecular-annotation/src/decode.rs | 21 +- molecular-annotation/src/iter.rs | 11 +- molecular-annotation/src/lib.rs | 6 + molecular-annotation/src/liftover.rs | 135 ++- molecular-annotation/src/tests.rs | 216 +++++ src/fiber.rs | 57 +- src/main.rs | 1 + src/subcommands/ddda_to_m6a.rs | 11 + src/subcommands/fire.rs | 20 + src/subcommands/footprint.rs | 8 +- src/subcommands/predict_m6a.rs | 9 + src/subcommands/qc.rs | 29 +- src/utils/input_bam.rs | 2 +- src/utils/ma_io.rs | 780 ++++++++++++++---- src/utils/nucleosome.rs | 14 + tests/data/ont_hardclip_full_frame.bam | Bin 0 -> 22887 bytes tests/data/ont_hardclip_full_frame.bam.bai | Bin 0 -> 512 bytes tests/data/ont_hardclip_full_frame.center.bed | 2 + tests/molecular_annotation.rs | 60 ++ tests/nucleosome.rs | 26 + tests/regression/center.rs | 73 ++ tests/regression/common.rs | 18 + tests/regression/convert_tags.rs | 66 +- tests/regression/extract.rs | 235 +++++- tests/regression/fire.rs | 149 +++- tests/regression/pileup.rs | 58 ++ tests/regression/qc.rs | 33 +- 33 files changed, 2032 insertions(+), 201 deletions(-) create mode 100644 tests/data/ont_hardclip_full_frame.bam create mode 100644 tests/data/ont_hardclip_full_frame.bam.bai create mode 100644 tests/data/ont_hardclip_full_frame.center.bed diff --git a/README.md b/README.md index 45e4bc41e..fa3354929 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ft --help ## Input BAM requirements -Fiber-seq tags (`MM`/`ML`, `ns`/`nl`/`as`/`al`, `Ma`) describe the full read. Aligners that hard-clip supplementary alignments copy those tags unchanged onto the clipped record, where they no longer match `SEQ`. `ft` drops the tags on such records and warns. To keep calls on supplementary alignments, align with soft clipping: +Fiber-seq tags (`MM`/`ML`, `ns`/`nl`/`as`/`al`, `Ma`) describe the full read. Aligners that hard-clip supplementary alignments copy those tags unchanged onto the clipped record. When the copied `Ma` or legacy `ns`/`nl`/`as`/`al` tags describe the full read, `ft` keeps the nucleosome, MSP and FIRE calls and lifts them with the hard-clip offset, but the `MM`/`ML` m6A cannot be recovered and is dropped, so those reads count as NotCallable. Tags that match neither the record nor the full read are dropped. `ft` warns in both cases. To keep m6A on supplementary alignments, align with soft clipping: - PacBio: `pbmm2 align` (it never hard-clips). - ONT: `dorado aligner --mm2-opts "-Y" ...`, or `samtools fastq -T '*' in.bam | minimap2 -Y -y -ax map-ont ref.fa -`. minimap2 also writes SEQ-less secondary alignments by default; their tags are dropped too, so add `--secondary=no` or filter with `-F 256`. diff --git a/molecular-annotation/CHANGELOG.md b/molecular-annotation/CHANGELOG.md index 5f2d44272..c82701bca 100644 --- a/molecular-annotation/CHANGELOG.md +++ b/molecular-annotation/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `AlignedBlocks::with_query_offset` / `query_offset`, `MolecularAnnotations::query_offset` / `set_query_offset`, and `liftover::{hard_clips, query_span, full_read_query_offset}`: lift full-read MA annotations on hard-clipped records ([#136](https://github.com/fiberseq/fibertools-rs/issues/136)). `from_record` (Rust and pyMA) no longer parses MM/ML in that frame, and `project_query` is SEQ-relative. pyMA `set_aligned_blocks` gains a `query_offset` keyword. + ## [0.0.3](https://github.com/fiberseq/fibertools-rs/compare/molecular-annotation-v0.0.2...molecular-annotation-v0.0.3) - 2026-08-13 ### Fixed diff --git a/molecular-annotation/python/molecular_annotation/pysam_utils.py b/molecular-annotation/python/molecular_annotation/pysam_utils.py index d8e775929..a1f60e400 100644 --- a/molecular-annotation/python/molecular_annotation/pysam_utils.py +++ b/molecular-annotation/python/molecular_annotation/pysam_utils.py @@ -99,6 +99,38 @@ def _parse_mm_ml_into( annot.parse_mm_ml(mm, ml, forward_seq) +_BAM_CHARD_CLIP = 5 + + +def _hard_clips(record: "pysam.AlignedSegment") -> tuple[int, int]: + """(leading, trailing) hard clip lengths, in BAM/CIGAR orientation. + + The CIGAR is in reference orientation, so "leading" is the same end on + both strands: the bases before the first base of SEQ. + """ + ct = record.cigartuples or [] + lead = ct[0][1] if ct and ct[0][0] == _BAM_CHARD_CLIP else 0 + trail = ct[-1][1] if len(ct) > 1 and ct[-1][0] == _BAM_CHARD_CLIP else 0 + return lead, trail + + +def _query_span(record: "pysam.AlignedSegment") -> int: + """Query bases the record holds: len(SEQ), or the CIGAR query span + (hard clips excluded) when SEQ is absent.""" + if record.query_sequence is not None: + return len(record.query_sequence) + return record.infer_query_length() or 0 + + +def _full_read_query_offset(read_length: int, record: "pysam.AlignedSegment"): + """Mirror of the Rust `full_read_query_offset`: the leading hard clip + when `read_length` spans the record's hard-clipped bases, else None.""" + lead, trail = _hard_clips(record) + if lead + trail > 0 and read_length == _query_span(record) + lead + trail: + return lead + return None + + def from_record( record: "pysam.AlignedSegment", parse_tags: bool = True ) -> MolecularAnnotations: @@ -117,12 +149,18 @@ def from_record( Returns: MolecularAnnotations object with aligned blocks set for liftover support. + On a hard-clipped record whose MA read length spans the hard-clipped + bases (an aligner copied the full read's tags onto the clipped + record), the MA annotations are kept in the full read's frame, the + liftover carries the leading hard clip as `query_offset`, and MM/ML + are not parsed (they are SEQ-relative and cannot be recovered). Raises: KeyError: If parse_tags=True and MA tag is missing ValueError: If tag format is invalid """ is_reverse = record.is_reverse + query_offset = 0 if parse_tags: # Resolve the Ma/Aq/An family atomically (required when parsing @@ -135,8 +173,17 @@ def from_record( annot = MolecularAnnotations.from_tags(ma, aq=aq, an=an) annot.is_reverse_aligned = is_reverse - # Base modifications (m6A/5mC/…) live in MM/ML, not the MA tag set. - _parse_mm_ml_into(annot, record, is_reverse) + # A hard-clipping aligner copies the full read's tags onto the + # clipped record. The MA family is then still correct (molecular + # coordinates of the full read); only the lift needs to know SEQ + # starts H_lead bases in. MM/ML are SEQ-relative and cannot be + # recovered, so they are not parsed in that frame. + offset = _full_read_query_offset(annot.read_length, record) + if offset is not None: + query_offset = offset + else: + # Base modifications (m6A/5mC/…) live in MM/ML, not the MA tag set. + _parse_mm_ml_into(annot, record, is_reverse) else: # Create empty annotations with just read length annot = MolecularAnnotations(record.query_length) @@ -148,7 +195,9 @@ def from_record( if not record.is_unmapped and record.cigartuples: aligned_blocks = _extract_aligned_blocks(record) if aligned_blocks: - annot.set_aligned_blocks(aligned_blocks, is_reverse=is_reverse) + annot.set_aligned_blocks( + aligned_blocks, is_reverse=is_reverse, query_offset=query_offset + ) return annot diff --git a/molecular-annotation/python/src/lib.rs b/molecular-annotation/python/src/lib.rs index 975d217c3..bc569c4a6 100644 --- a/molecular-annotation/python/src/lib.rs +++ b/molecular-annotation/python/src/lib.rs @@ -40,6 +40,7 @@ use pyo3::prelude::*; // Use fully qualified path to avoid name collision with the pymodule use ::molecular_annotation::{ + AlignedBlocks as RustAlignedBlocks, Annotation as RustAnnotation, Encoding as RustEncoding, MolecularAnnotations as RustMolecularAnnotations, @@ -590,26 +591,42 @@ impl MolecularAnnotations { /// Set aligned blocks for liftover calculations. /// - /// Accepts 0-based half-open [start, end) intervals. + /// Accepts 0-based half-open [start, end) intervals. Block query + /// coordinates are SEQ-relative (as pysam reports them). /// /// Args: /// blocks: List of ((query_start, query_end), (ref_start, ref_end)) tuples. /// is_reverse: Whether the read is reverse-aligned. + /// query_offset: Where SEQ starts in the annotation frame. Pass the + /// leading hard clip when the annotations describe the full + /// read and this record is a hard-clipped part of it; 0 otherwise. /// /// Example: /// >>> # Query [0, 500) aligns to reference [1000, 1500) /// >>> annot.set_aligned_blocks([((0, 500), (1000, 1500))], is_reverse=False) - #[pyo3(signature = (blocks, is_reverse=false))] + #[pyo3(signature = (blocks, is_reverse=false, query_offset=0))] pub fn set_aligned_blocks( &mut self, blocks: Vec<((u32, u32), (u32, u32))>, is_reverse: bool, + query_offset: u32, ) { let block_pairs: Vec<([u32; 2], [u32; 2])> = blocks .into_iter() .map(|((qs, qe), (rs, re))| ([qs, qe], [rs, re])) .collect(); - self.inner.set_aligned_blocks(block_pairs, is_reverse); + let blocks = RustAlignedBlocks::new(block_pairs, self.inner.read_length) + .with_query_offset(query_offset); + self.inner.set_aligned_blocks_raw(blocks, is_reverse); + } + + /// Where SEQ starts in the annotation frame: 0 unless the annotations + /// describe the full read and this record is a hard-clipped part of it. + /// Query coordinates from get_coords / get_ref_coords / iter_type are in + /// the annotation frame; subtract this before indexing the sequence. + #[getter] + pub fn query_offset(&self) -> u32 { + self.inner.query_offset() } /// Check if aligned blocks are set. diff --git a/molecular-annotation/python/tests/test_molecular_annotation.py b/molecular-annotation/python/tests/test_molecular_annotation.py index 86a45c015..8db28c7d0 100644 --- a/molecular-annotation/python/tests/test_molecular_annotation.py +++ b/molecular-annotation/python/tests/test_molecular_annotation.py @@ -888,3 +888,86 @@ def test_add_annotation_then_write(self, tmp_path): assert len(annot2.iter_type("nuc")) == nuc_before # ...and the MM/ML base mods are still intact. assert len(annot2.iter_type("a")) == 1541 + + +class TestFullReadFrame: + """A hard-clipped record whose MA read length spans the hard-clipped + bases keeps its full-read annotations; only the lift carries the + leading hard clip (#136).""" + + def _rec(self, pysam, cigar, seq_len, ma, flag=0, mm=False): + r = TestAlignedBlocks()._record(pysam, cigar, seq_len) # ref_start 1000 + r.flag = flag + r.set_tag("Ma", ma) + if mm: + r.set_tag("MM", "A+a,0;") + r.set_tag("ML", [200]) + return r + + def test_hard_clips(self): + pysam = pytest.importorskip("pysam") + from molecular_annotation.pysam_utils import _hard_clips + + rec = TestAlignedBlocks()._record + assert _hard_clips(rec(pysam, "5H10S40M2D40M10S5H", 100)) == (5, 5) + assert _hard_clips(rec(pysam, "100M", 100)) == (0, 0) + assert _hard_clips(rec(pysam, "80M20H", 80)) == (0, 20) + + def test_forward_leading_hard_clip(self): + pysam = pytest.importorskip("pysam") + from molecular_annotation.pysam_utils import from_record + + # molecular [60,80) of a 150 bp read; SEQ is [50,150) + annot = from_record(self._rec(pysam, "50H100M", 100, "150;nuc.:61-20", mm=True)) + assert annot.read_length == 150 + assert annot.query_offset == 50 + assert annot.get_ref_coords("nuc") == [(60, 80, 1010, 1030)] + assert "a" not in annot.annotation_type_names(), "MM/ML must not be parsed" + + def test_reverse_trailing_hard_clip(self): + pysam = pytest.importorskip("pysam") + from molecular_annotation.pysam_utils import from_record + + annot = from_record(self._rec(pysam, "100M50H", 100, "150;nuc.:61-20", flag=16)) + assert annot.query_offset == 0 + assert annot.get_ref_coords("nuc") == [(70, 90, 1070, 1090)] + + def test_reverse_leading_hard_clip(self): + pysam = pytest.importorskip("pysam") + from molecular_annotation.pysam_utils import from_record + + annot = from_record(self._rec(pysam, "50H100M", 100, "150;nuc.:61-20", flag=16)) + assert annot.query_offset == 50 + assert annot.get_ref_coords("nuc") == [(70, 90, 1020, 1040)] + + def test_clipped_frame_has_no_offset(self): + pysam = pytest.importorskip("pysam") + from molecular_annotation.pysam_utils import from_record + + annot = from_record(self._rec(pysam, "50H100M", 100, "100;nuc.:11-20", mm=True)) + assert annot.query_offset == 0 + assert annot.get_ref_coords("nuc") == [(10, 30, 1010, 1030)] + assert "a" in annot.annotation_type_names(), "MA vouches for MM/ML" + + def test_before_seq_does_not_lift(self): + pysam = pytest.importorskip("pysam") + from molecular_annotation.pysam_utils import from_record + + annot = from_record(self._rec(pysam, "50H100M", 100, "150;nuc.:11-20")) + assert annot.get_ref_coords("nuc") == [(10, 30, None, None)] + + def test_set_aligned_blocks_default_offset(self): + annot = MolecularAnnotations(80) + annot.set_aligned_blocks([((0, 80), (1000, 1080))], is_reverse=False) + assert annot.query_offset == 0 + annot.set_aligned_blocks([((0, 80), (1000, 1080))], query_offset=10) + assert annot.query_offset == 10 + + def test_round_trip_keeps_full_read_frame(self): + pysam = pytest.importorskip("pysam") + from molecular_annotation.pysam_utils import from_record, to_record + + r = self._rec(pysam, "50H100M", 100, "150;nuc.:61-20") + annot = from_record(r) + to_record(annot, r) + assert from_record(r).to_ma_string().startswith("150;") diff --git a/molecular-annotation/src/coords.rs b/molecular-annotation/src/coords.rs index 709232dad..1e35b72bc 100644 --- a/molecular-annotation/src/coords.rs +++ b/molecular-annotation/src/coords.rs @@ -59,6 +59,26 @@ impl MolecularAnnotations { self.aligned_blocks.as_ref() } + /// Where SEQ starts in the annotation frame (see + /// [`AlignedBlocks::query_offset`]); 0 without aligned blocks or when + /// the annotation frame is SEQ. BAM-orientation query coordinates from + /// `get_coords`, `get_ref_coords` and `iter_type` are in the annotation + /// frame: subtract this (checked) before indexing SEQ with them, and + /// bound the result by SEQ's length. `project_query` already subtracts + /// it. + pub fn query_offset(&self) -> u32 { + self.aligned_blocks.as_ref().map_or(0, |b| b.query_offset()) + } + + /// Set where SEQ starts in the annotation frame on the aligned blocks + /// (see [`query_offset`](Self::query_offset)). Call after the blocks are + /// set; without blocks there is nothing to lift and this is a no-op. + pub fn set_query_offset(&mut self, offset: u32) { + if let Some(b) = self.aligned_blocks.take() { + self.aligned_blocks = Some(b.with_query_offset(offset)); + } + } + /// Check if the read is reverse-aligned. pub fn is_reverse_aligned(&self) -> bool { self.is_reverse_aligned @@ -89,8 +109,10 @@ impl MolecularAnnotations { /// Get coordinates for a specific annotation type in BAM orientation. /// /// Query coordinates are returned in **BAM orientation** (forward-oriented, matching - /// the sequence as stored in the BAM file). For reverse-aligned reads, this means - /// the coordinates are flipped from the original molecular orientation. + /// the sequence as stored in the BAM file, or, on a hard-clipped record whose + /// annotations describe the full read, that full read in BAM orientation: SEQ + /// starts at [`query_offset`](Self::query_offset)). For reverse-aligned reads, + /// this means the coordinates are flipped from the original molecular orientation. /// /// This is analogous to pysam's `modified_bases` which returns positions relative /// to the BAM sequence. For original molecular orientation, use diff --git a/molecular-annotation/src/decode.rs b/molecular-annotation/src/decode.rs index 888450645..c7808388d 100644 --- a/molecular-annotation/src/decode.rs +++ b/molecular-annotation/src/decode.rs @@ -60,6 +60,10 @@ impl MolecularAnnotations { /// from the record itself, so liftover-based getters (`ref_coords`, etc.) /// work without additional setup. /// + /// On a hard-clipped record whose MA read length spans the hard-clipped + /// bases, the aligned blocks carry the leading hard clip as query offset + /// and MM/ML are not parsed (see [`crate::full_read_query_offset`]). + /// /// **Idempotency:** this is the only public entry point that parses MM/ML. /// Each call constructs a fresh `MolecularAnnotations`; there is no API /// to re-parse into an existing object. @@ -95,8 +99,23 @@ impl MolecularAnnotations { None => Self::new(record.seq_len() as u32), }; - annot.aligned_blocks = Some(crate::AlignedBlocks::from_record(record)); + // Annotation frame vs SEQ. Aligners that hard-clip (minimap2 and + // dorado aligner without -Y) copy the full-length read's tags onto + // the clipped supplementary record. The MA family is then still + // correct: its coordinates are molecular coordinates of the full + // read, and only the lift needs to know that SEQ starts H_lead + // bases into that frame. MM/ML are SEQ-relative deltas that cannot + // be recovered without the clipped bases, so in that frame they are + // not parsed (writers strip them). + let full_read_offset = crate::liftover::full_read_query_offset(annot.read_length, record); + annot.aligned_blocks = Some( + crate::AlignedBlocks::from_record(record) + .with_query_offset(full_read_offset.unwrap_or(0)), + ); annot.is_reverse_aligned = record.is_reverse(); + if full_read_offset.is_some() { + return annot; + } // Extract MM/ML and the forward-oriented sequence off the record, then // hand the raw slices to the htslib-free parser. MM is always written diff --git a/molecular-annotation/src/iter.rs b/molecular-annotation/src/iter.rs index 94d90d030..45cd11b26 100644 --- a/molecular-annotation/src/iter.rs +++ b/molecular-annotation/src/iter.rs @@ -131,14 +131,16 @@ impl MolecularAnnotations { /// Project annotations into a query-coordinate system anchored at 0. /// - /// Each annotation's coords (in BAM orientation, matching `iter_full`) are - /// shifted by `-anchor`. If `flip` is true, each interval is reversed - /// around 0: `[a, b)` → `[-(b-1), -(a-1))`. + /// Each annotation's coords (in BAM orientation, matching `iter_full`, + /// then made SEQ-relative by subtracting [`query_offset`](Self::query_offset)) + /// are shifted by `-anchor`, so `anchor` is a SEQ position. If `flip` is + /// true, each interval is reversed around 0: `[a, b)` → `[-(b-1), -(a-1))`. pub fn project_query( &self, anchor: i64, flip: bool, ) -> impl Iterator> + '_ { + let offset = self.query_offset() as i64; self.annotation_types.iter().flat_map(move |t| { t.annotations.iter().map(move |a| { let (qs, qe) = if self.is_reverse_aligned { @@ -146,7 +148,8 @@ impl MolecularAnnotations { } else { (a.start, a.end()) }; - let (start, end) = project_interval(qs as i64, qe as i64, anchor, flip); + let (start, end) = + project_interval(qs as i64 - offset, qe as i64 - offset, anchor, flip); ProjectedAnnotation { type_name: &t.name, start, diff --git a/molecular-annotation/src/lib.rs b/molecular-annotation/src/lib.rs index d49d3010d..36575d462 100644 --- a/molecular-annotation/src/lib.rs +++ b/molecular-annotation/src/lib.rs @@ -26,6 +26,10 @@ //! | `get_coords()` | BAM | Coordinates as stored in BAM (flipped for reverse reads) | //! | `get_forward_coords()` | Molecular | Original read orientation (never flipped) | //! +//! Query coordinates are in the annotation frame. On a hard-clipped record +//! whose annotations describe the full read, SEQ starts `query_offset()` +//! bases into that frame; only the liftover applies the offset. +//! //! # Module layout //! //! The public surface lives behind re-exports from this crate root. Internally @@ -131,6 +135,8 @@ mod basemods; #[cfg(feature = "htslib")] pub use decode::ma_family_tags; +#[cfg(feature = "htslib")] +pub use liftover::{full_read_query_offset, hard_clips, query_span}; pub use liftover::{AlignedBlock, AlignedBlocks}; pub use types::{ Annotation, AnnotationInfo, AnnotationType, Encoding, LiftedCoords, MaParts, MmGroup, diff --git a/molecular-annotation/src/liftover.rs b/molecular-annotation/src/liftover.rs index f5fe8df01..3eb161083 100644 --- a/molecular-annotation/src/liftover.rs +++ b/molecular-annotation/src/liftover.rs @@ -108,6 +108,14 @@ pub struct AlignedBlocks { blocks: Vec, /// Length of the query sequence pub query_len: u32, + /// Where SEQ starts in the annotation frame. Block query coordinates + /// are always SEQ-relative (from the CIGAR). Callers pass query + /// coordinates in the frame the annotations were made in; when that + /// frame is the full read and this record holds a hard-clipped part of + /// it, SEQ starts `query_offset` bases in (the leading hard clip, in + /// BAM/CIGAR orientation). `lift_to_reference` subtracts it and + /// `lift_to_query` adds it. 0 when the annotation frame is SEQ. + query_offset: u32, } impl AlignedBlocks { @@ -132,7 +140,11 @@ impl AlignedBlocks { .into_iter() .map(|([q_st, q_en], [r_st, r_en])| AlignedBlock::new(q_st, q_en, r_st, r_en)) .collect(); - Self { blocks, query_len } + Self { + blocks, + query_len, + query_offset: 0, + } } /// Create `AlignedBlocks` from an iterator of block pairs. @@ -146,7 +158,26 @@ impl AlignedBlocks { let blocks = iter .map(|([q_st, q_en], [r_st, r_en])| AlignedBlock::new(q_st, q_en, r_st, r_en)) .collect(); - Self { blocks, query_len } + Self { + blocks, + query_len, + query_offset: 0, + } + } + + /// Set where SEQ starts in the annotation frame (see `query_offset`). + /// Use the leading hard clip when the annotations describe the full + /// read and this record is a hard-clipped part of it. + pub fn with_query_offset(mut self, query_offset: u32) -> Self { + self.query_offset = query_offset; + self + } + + /// Where SEQ starts in the annotation frame; 0 unless the annotations + /// describe the full read and SEQ is a hard-clipped part of it. + #[inline] + pub fn query_offset(&self) -> u32 { + self.query_offset } /// Check if there are any aligned blocks. @@ -167,7 +198,8 @@ impl AlignedBlocks { /// Lift a range from query to reference coordinates. /// /// # Arguments - /// * `start` - 0-based query start position (inclusive) + /// * `start` - 0-based query start position (inclusive), in the + /// annotation frame (see `query_offset`) /// * `end` - 0-based query end position (exclusive) /// /// # Returns @@ -194,6 +226,13 @@ impl AlignedBlocks { /// assert_eq!(re, Some(1050)); /// ``` pub fn lift_to_reference(&self, start: u32, end: u32) -> (Option, Option) { + // Annotation frame -> SEQ-relative. Bases before SEQ (inside the + // leading hard clip) clamp to 0: a range that straddles the clip + // snaps forward into the first aligned base, exactly as it would + // across a soft clip; a range that ends before SEQ collapses to + // `start >= end` and lifts to nothing. + let start = start.saturating_sub(self.query_offset); + let end = end.saturating_sub(self.query_offset); if self.blocks.is_empty() || start >= end { return (None, None); } @@ -233,7 +272,8 @@ impl AlignedBlocks { /// * `end` - 0-based reference end position (exclusive) /// /// # Returns - /// Tuple of `(query_start, query_end)` as 0-based half-open interval. + /// Tuple of `(query_start, query_end)` as 0-based half-open interval, in + /// the annotation frame (see `query_offset`). /// Returns `(None, None)` if the range cannot be lifted. /// /// # Behavior @@ -250,6 +290,7 @@ impl AlignedBlocks { if range_len == 1 { // 1bp interval: require exact match if let Some(query_pos) = self.lift_exact_to_query(start) { + let query_pos = query_pos + self.query_offset; return (Some(query_pos), Some(query_pos + 1)); } return (None, None); @@ -267,7 +308,9 @@ impl AlignedBlocks { let query_end = self.lift_end_to_query(start, end); match (query_start, query_end) { - (Some(qs), Some(qe)) if qs < qe => (Some(qs), Some(qe)), + (Some(qs), Some(qe)) if qs < qe => { + (Some(qs + self.query_offset), Some(qe + self.query_offset)) + } _ => (None, None), } } @@ -438,6 +481,57 @@ impl AlignedBlocks { } } +/// Leading and trailing hard clips of a record, in BAM/CIGAR orientation. +/// The CIGAR is written in reference orientation, so "leading" is the same +/// end on both strands: the bases before the first base of SEQ. +#[cfg(feature = "htslib")] +pub fn hard_clips(record: &rust_htslib::bam::Record) -> (u32, u32) { + let cigar = record.cigar(); + ( + cigar.leading_hardclips() as u32, + cigar.trailing_hardclips() as u32, + ) +} + +/// Query bases the record holds: the SEQ length, or, when SEQ is absent +/// (`*`), the CIGAR's query span (M/I/S/=/X; hard clips excluded). +#[cfg(feature = "htslib")] +pub fn query_span(record: &rust_htslib::bam::Record) -> u32 { + use rust_htslib::bam::record::Cigar; + let seq_len = record.seq_len() as u32; + if seq_len > 0 { + return seq_len; + } + record + .cigar() + .iter() + .map(|c| match c { + Cigar::Match(l) + | Cigar::Ins(l) + | Cigar::SoftClip(l) + | Cigar::Equal(l) + | Cigar::Diff(l) => *l, + _ => 0, + }) + .sum() +} + +/// The query offset to lift with when `read_length` (the frame the +/// annotations were made in) is the full read and `record` is a +/// hard-clipped part of it: `Some(H_lead)` iff the record has a hard clip +/// and `read_length == query_span + H_lead + H_trail`. `None` when the +/// frame is SEQ (no hard clips, or tags computed after clipping) or when +/// it matches neither (the caller decides what to do with those). +#[cfg(feature = "htslib")] +pub fn full_read_query_offset(read_length: u32, record: &rust_htslib::bam::Record) -> Option { + let (lead, trail) = hard_clips(record); + if lead + trail > 0 && read_length == query_span(record) + lead + trail { + Some(lead) + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; @@ -843,4 +937,35 @@ mod tests { let b = test_blocks(); assert_eq!(b.lift_to_query(103, 108), (Some(5), Some(7))); } + + // A full-read annotation frame on a hard-clipped record: SEQ starts + // `query_offset` bases in. Lifts subtract it, reverse lifts add it. + #[test] + fn test_query_offset_forward() { + let b = AlignedBlocks::new(vec![([0, 80], [1000, 1080])], 80).with_query_offset(10); + assert_eq!(b.query_offset(), 10); + assert_eq!(b.lift_to_reference(20, 50), (Some(1010), Some(1040))); + // straddling the clip snaps into the first aligned base + assert_eq!(b.lift_to_reference(5, 30), (Some(1000), Some(1020))); + // entirely inside the clip lifts to nothing, no underflow + assert_eq!(b.lift_to_reference(0, 10), (None, None)); + assert_eq!(b.lift_to_reference(7, 8), (None, None)); + assert_eq!(b.lift_to_query(1010, 1040), (Some(20), Some(50))); + assert_eq!(b.lift_to_query(1000, 1001), (Some(10), Some(11))); + assert_eq!( + AlignedBlocks::new(vec![([0, 80], [1000, 1080])], 80).query_offset(), + 0 + ); + assert_eq!(AlignedBlocks::default().query_offset(), 0); + } + + #[test] + fn test_query_offset_with_gaps() { + let b = test_blocks().with_query_offset(3); + // same as the unshifted [0,2) + assert_eq!(b.lift_to_reference(3, 5), (Some(100), Some(102))); + // unshifted [2,3): the insertion gap + assert_eq!(b.lift_to_reference(5, 6), (None, None)); + assert_eq!(b.lift_to_query(107, 111), (Some(9), Some(13))); + } } diff --git a/molecular-annotation/src/tests.rs b/molecular-annotation/src/tests.rs index 922c267d5..83e0a4c41 100644 --- a/molecular-annotation/src/tests.rs +++ b/molecular-annotation/src/tests.rs @@ -607,6 +607,91 @@ fn test_get_ref_coords_reverse_outside_aligned_region() { assert_eq!(coords[0].3, None); } +// A full-read annotation frame on a hard-clipped record (#136): query +// coordinates stay in that frame, only the lift and project_query subtract +// the leading hard clip. +#[test] +fn test_query_offset_forward_lifts_through_leading_hard_clip() { + // 150 bp read, CIGAR 50H100M: SEQ is molecular [50,150), ref [1000,1100) + let mut a = MolecularAnnotations::new(150); + a.add_annotation_type("nuc", QualitySpec::none(), Encoding::Ma) + .add(60, 20, Strand::Unknown, vec![], None) // [60,80): inside SEQ + .add(10, 20, Strand::Unknown, vec![], None) // [10,30): entirely in the clip + .add(40, 20, Strand::Unknown, vec![], None); // [40,60): straddles the clip + a.set_aligned_blocks(vec![([0, 100], [1000, 1100])], false); + assert_eq!(a.query_offset(), 0); + a.set_query_offset(50); + assert_eq!(a.query_offset(), 50); + let c = a.get_ref_coords("nuc").unwrap(); + assert_eq!( + c[0], + (60, 80, Some(1010), Some(1030)), + "query coords stay full-frame, ref uses the offset" + ); + assert_eq!( + c[1], + (10, 30, None, None), + "annotation before SEQ does not lift and does not panic" + ); + assert_eq!( + c[2], + (40, 60, Some(1000), Some(1010)), + "straddling annotation snaps like a soft clip" + ); + let infos: Vec<_> = a.iter_type("nuc").unwrap().collect(); + assert_eq!( + ( + infos[0].query_start, + infos[0].query_end, + infos[0].ref_start, + infos[0].ref_end + ), + (60, 80, Some(1010), Some(1030)) + ); + let pq: Vec<(i64, i64)> = a + .project_query(0, false) + .map(|p| (p.start, p.end)) + .collect(); + assert_eq!( + pq, + vec![(10, 30), (-40, -20), (-10, 10)], + "project_query is SEQ-relative" + ); + let pr: Vec<(i64, i64)> = a + .project_reference(1000, false) + .map(|p| (p.start, p.end)) + .collect(); + assert_eq!(pr, vec![(10, 30), (0, 10)]); + // the container's raw lift is in the annotation frame too + assert_eq!(a.lift_to_reference(60, 80), Some((Some(1010), Some(1030)))); + assert_eq!(a.lift_to_query(1010, 1030), Some((Some(60), Some(80)))); +} + +#[test] +fn test_query_offset_reverse_aligned() { + // molecular [60,80) of a 150 bp reverse read -> BAM [70,90) + let mut a = MolecularAnnotations::new(150); + a.add_annotation_type("nuc", QualitySpec::none(), Encoding::Ma) + .add(60, 20, Strand::Unknown, vec![], None); + a.set_aligned_blocks(vec![([0, 100], [1000, 1100])], true); + // CIGAR 100M50H (trailing clip): no offset, today's result + assert_eq!( + a.get_ref_coords("nuc").unwrap(), + vec![(70, 90, Some(1070), Some(1090))] + ); + // CIGAR 50H100M on a reverse read: BAM [70,90) is SEQ [20,40) + a.set_query_offset(50); + assert_eq!( + a.get_ref_coords("nuc").unwrap(), + vec![(70, 90, Some(1020), Some(1040))] + ); + let pq: Vec<(i64, i64)> = a + .project_query(0, false) + .map(|p| (p.start, p.end)) + .collect(); + assert_eq!(pq, vec![(20, 40)]); +} + #[test] fn test_flip_range() { let annotations = MolecularAnnotations::new(1000); @@ -2268,3 +2353,134 @@ fn ma_family_tags_accept_both_spellings() { } assert!(matches!(record.aux(b"Ma"), Ok(Aux::String(_)))); } + +/// A mapped record at reference position 1000 with the given SEQ, CIGAR and +/// flags, for the hard-clip frame tests below. +#[cfg(feature = "htslib")] +fn aligned_record(seq: &[u8], cigar: &str, flags: u16) -> rust_htslib::bam::Record { + use rust_htslib::bam::record::CigarString; + let mut r = rust_htslib::bam::Record::new(); + let cigar = CigarString::try_from(cigar).unwrap(); + r.set(b"read", Some(&cigar), seq, &vec![255u8; seq.len()]); + r.set_flags(flags); + r.set_tid(0); + r.set_pos(1000); + r +} + +#[cfg(feature = "htslib")] +fn with_ma_and_mm(seq: &[u8], cigar: &str, flags: u16, ma: &str) -> rust_htslib::bam::Record { + use rust_htslib::bam::record::Aux; + let mut r = aligned_record(seq, cigar, flags); + r.push_aux(b"Ma", Aux::String(ma)).unwrap(); + r.push_aux(b"MM", Aux::String("A+a,0;")).unwrap(); + r.push_aux(b"ML", Aux::ArrayU8((&[200u8][..]).into())) + .unwrap(); + r +} + +// MA read length == SEQ + hard clips: the tags describe the full read. The +// lift carries the leading hard clip; MM/ML are not parsed. +#[cfg(feature = "htslib")] +#[test] +fn from_record_full_read_frame_forward() { + let seq = vec![b'A'; 80]; + // molecular [20,50) of a 100 bp read; SEQ is [10,90) + let r = with_ma_and_mm(&seq, "10H80M10H", 0, "100;msp.:21-30"); + let annot = MolecularAnnotations::from_record(&r); + assert_eq!(annot.read_length, 100); + assert_eq!(annot.query_offset(), 10); + assert_eq!( + annot.get_ref_coords("msp"), + Some(vec![(20, 50, Some(1010), Some(1040))]) + ); + assert!(annot.get_type("a").is_none(), "MM/ML must not be parsed"); + assert!(annot.to_ma_string().starts_with("100;"), "frame kept as is"); +} + +#[cfg(feature = "htslib")] +#[test] +fn from_record_full_read_frame_reverse() { + let seq = vec![b'A'; 80]; + // molecular [20,50) -> flip with L=100 -> BAM [50,80) -> SEQ [45,75) + let r = with_ma_and_mm(&seq, "5H80M15H", 16, "100;msp.:21-30"); + let annot = MolecularAnnotations::from_record(&r); + assert_eq!(annot.query_offset(), 5); + assert_eq!(annot.get_coords("msp"), Some(vec![(50, 80)])); + assert_eq!( + annot.get_ref_coords("msp"), + Some(vec![(50, 80, Some(1045), Some(1075))]) + ); + // molecular [0,10) -> BAM [90,100) -> SEQ [85,95): past the 80 bp SEQ + let r = with_ma_and_mm(&seq, "5H80M15H", 16, "100;msp.:1-10"); + let annot = MolecularAnnotations::from_record(&r); + assert_eq!(annot.get_ref_coords("msp").unwrap()[0].2, None); + // trailing clip only: still the full-read frame, offset 0 + let r = with_ma_and_mm(&seq, "80M20H", 16, "100;msp.:31-10"); + let annot = MolecularAnnotations::from_record(&r); + assert_eq!(annot.query_offset(), 0); + assert_eq!( + annot.get_ref_coords("msp"), + Some(vec![(60, 70, Some(1060), Some(1070))]) + ); + assert!(annot.get_type("a").is_none()); +} + +// Every other shape takes the old path: no offset, MM/ML parsed. +#[cfg(feature = "htslib")] +#[test] +fn from_record_seq_frame_is_unchanged() { + use rust_htslib::bam::record::Aux; + let seq = vec![b'A'; 80]; + // tags computed after clipping + let r = with_ma_and_mm(&seq, "10H80M10H", 0, "80;msp.:21-30"); + let annot = MolecularAnnotations::from_record(&r); + assert_eq!(annot.query_offset(), 0); + assert_eq!( + annot.get_ref_coords("msp"), + Some(vec![(20, 50, Some(1020), Some(1050))]) + ); + assert!(annot.get_type("a").is_some()); + // soft clip + let r = with_ma_and_mm(&seq, "10S70M", 0, "80;msp.:21-30"); + let annot = MolecularAnnotations::from_record(&r); + assert_eq!(annot.query_offset(), 0); + assert_eq!( + annot.get_ref_coords("msp"), + Some(vec![(20, 50, Some(1010), Some(1040))]) + ); + // no MA tag with hard clips + let mut r = aligned_record(&seq, "10H80M10H", 0); + r.push_aux(b"MM", Aux::String("A+a,0;")).unwrap(); + r.push_aux(b"ML", Aux::ArrayU8((&[200u8][..]).into())) + .unwrap(); + let annot = MolecularAnnotations::from_record(&r); + assert_eq!((annot.read_length, annot.query_offset()), (80, 0)); + assert!(annot.get_type("a").is_some()); + // a read length matching neither: the library leaves it to the caller + let r = with_ma_and_mm(&seq, "10H80M10H", 0, "90;msp.:21-30"); + assert_eq!(MolecularAnnotations::from_record(&r).query_offset(), 0); +} + +#[cfg(feature = "htslib")] +#[test] +fn hard_clip_frame_helpers() { + use crate::{full_read_query_offset, hard_clips, query_span}; + use rust_htslib::bam::record::CigarString; + let seq = vec![b'A'; 80]; + let r = aligned_record(&seq, "10H80M10H", 0); + assert_eq!(hard_clips(&r), (10, 10)); + assert_eq!(query_span(&r), 80); + assert_eq!(full_read_query_offset(100, &r), Some(10)); + assert_eq!(full_read_query_offset(80, &r), None); + assert_eq!(full_read_query_offset(90, &r), None); + // SEQ-less: the CIGAR query span stands in for SEQ + let mut seqless = rust_htslib::bam::Record::new(); + let cigar = CigarString::try_from("10H70M10S10H").unwrap(); + seqless.set(b"read", Some(&cigar), b"", &[]); + assert_eq!(query_span(&seqless), 80); + assert_eq!(full_read_query_offset(100, &seqless), Some(10)); + let r = aligned_record(&seq, "80M", 0); + assert_eq!(hard_clips(&r), (0, 0)); + assert_eq!(full_read_query_offset(80, &r), None); +} diff --git a/src/fiber.rs b/src/fiber.rs index 71a59b3ba..e228f11ef 100644 --- a/src/fiber.rs +++ b/src/fiber.rs @@ -185,26 +185,41 @@ impl FiberseqData { pub fn callable_state(&self) -> (CallableState, i64, i64) { // A tag whose recorded read length no longer matches the record is // stale: something rewrote the read after tagging. Treat as Untagged. - // A SEQ-less record (seq_len 0, SEQ dropped to save space) is NOT - // stale: the MA read length is the source of truth there. - if crate::utils::ma_io::read_length_is_stale( - self.annotations.read_length, - self.record.seq_len(), - ) { + // The frame is SEQ, or SEQ plus the hard clips for a full-read-frame + // record. A SEQ-less record (seq_len 0, SEQ dropped to save space) + // is NOT stale: the MA read length is the source of truth there. + if crate::utils::ma_io::frame_is_stale(&self.annotations, &self.record) { return (CallableState::Untagged, 0, 0); } + // Decide the 1-0 NotCallable marker on the raw model, before any + // liftover: the marker carries no position, and on a full-read-frame + // record position 0 sits inside the leading hard clip. + let Some(t) = self.annotations.get_type(FIBERSEQ_CALLABLE_TYPE) else { + return (CallableState::Untagged, 0, 0); + }; + let Some(raw) = t.annotations.first() else { + return (CallableState::Untagged, 0, 0); + }; + if raw.length == 0 { + return (CallableState::NotCallable, 0, 0); + } + // A hard-clipped part of a tagged read has no m6A (MM/ML were + // dropped), so it is never callable, whatever its inherited tag says. + if self.is_full_read_frame() { + return (CallableState::NotCallable, 0, 0); + } let view = self.fiberseq_callable(); let infos = view.infos(); let Some(a) = infos.first() else { - return (CallableState::Untagged, 0, 0); + return (CallableState::NotCallable, 0, 0); }; let len = self.frame_length() as i64; let (cs, ce) = ( (a.query_start as i64).clamp(0, len), (a.query_end as i64).clamp(0, len), ); - // Empty after clamping covers both the 1-0 NotCallable marker and - // a corrupt foreign span lying outside the frame. + // Empty after clamping covers a corrupt foreign span lying outside + // the frame. if ce <= cs { return (CallableState::NotCallable, cs, cs); } @@ -224,6 +239,14 @@ impl FiberseqData { } } + /// True when the annotations describe the full-length read of which + /// this record's SEQ is a hard-clipped part (#136). Query coordinates + /// from the views are then in that frame, SEQ starts + /// `annotations.query_offset()` bases in, and the read has no m6A. + pub fn is_full_read_frame(&self) -> bool { + crate::utils::ma_io::model_is_full_read_frame(&self.annotations, &self.record) + } + /// True when the read carries a non-empty `fiberseq_callable` span. pub fn is_callable(&self) -> bool { self.callable_state().0 == CallableState::Callable @@ -386,7 +409,13 @@ impl FiberseqData { } else { ct = &name; start = 0; - end = self.record.seq_len() as i64; + // The blocks are in the annotation frame: SEQ, or the full read + // this record was hard-clipped from. + end = if self.is_full_read_frame() { + self.annotations.read_length as i64 + } else { + self.record.seq_len() as i64 + }; } let score = self.ec.round() as i64; let strand = if self.record.is_reverse() { '-' } else { '+' }; @@ -489,7 +518,13 @@ impl FiberseqData { // PB features let name = std::str::from_utf8(self.record.qname()).unwrap(); let score = self.ec.round() as i64; - let q_len = self.record.seq_len() as i64; + // The molecular columns are in the annotation frame, which on a + // hard-clipped full-read-frame record is the whole read, not SEQ. + let q_len = if self.is_full_read_frame() { + self.annotations.read_length as i64 + } else { + self.record.seq_len() as i64 + }; let rq = match self.get_rq() { Some(x) => format!("{x}"), None => ".".to_string(), diff --git a/src/main.rs b/src/main.rs index 331b87721..50626d451 100644 --- a/src/main.rs +++ b/src/main.rs @@ -147,6 +147,7 @@ pub fn main() -> Result<(), Error> { None => {} }; utils::ma_io::report_stale_frames(); + utils::ma_io::report_full_frames(); let duration = pg_start.elapsed(); log::info!( "{} done! Time elapsed: {}", diff --git a/src/subcommands/ddda_to_m6a.rs b/src/subcommands/ddda_to_m6a.rs index 5b2e22d2c..6a39521f2 100644 --- a/src/subcommands/ddda_to_m6a.rs +++ b/src/subcommands/ddda_to_m6a.rs @@ -71,6 +71,17 @@ pub fn ddda_to_m6a_record(record: &mut Record, opts: &DddaToM6aOptions) { ); MolecularAnnotations::from_record(record) }); + if ma_io::model_is_full_read_frame(&annot, record) { + // The Y/R calls below are positions in SEQ; the inherited nuc/msp + // live in the full read's frame and cannot share a tag with them. + // Restart from SEQ. + annot.annotation_types.clear(); + annot.read_length = record.seq_len() as u32; + annot.set_aligned_blocks_raw( + molecular_annotation::AlignedBlocks::from_record(record), + record.is_reverse(), + ); + } // Verdict from the calling-time set, before the m6A rebuild. ma_io::sync_fiberseq_callable(&mut annot, record, &opts.input.filters); annot diff --git a/src/subcommands/fire.rs b/src/subcommands/fire.rs index 5e3c46710..0f26d68e1 100644 --- a/src/subcommands/fire.rs +++ b/src/subcommands/fire.rs @@ -9,12 +9,31 @@ use itertools::Itertools; use rayon::prelude::*; use utils::fire::*; +/// True when the record carries at least one m6A call in memory. A record +/// with msp but no m6A (a hard-clipped full-read-frame record whose MM/ML +/// were dropped, or one stripped on purpose) cannot be scored: FireFeats +/// indexes SEQ by m6A and MSP coordinates and needs both. +fn has_m6a(rec: &FiberseqData) -> bool { + rec.annotations + .get_type(crate::utils::basemods::M6A_TYPE) + .is_some_and(|t| !t.annotations.is_empty()) +} + pub fn add_fire_to_rec( rec: &mut FiberseqData, fire_opts: &FireOptions, model: &GBDT, precision_table: &MapPrecisionValues, ) { + if !has_m6a(rec) { + // No m6A, so no FIRE features. Write the model back unchanged: + // nuc/msp (and any fire calls made while the read still had m6A) + // are kept; the callable state synced at read time (NotCallable + // for a full-read-frame record) is written with them. + log::debug!("FIRE: no m6A on {}; writing it unscored", rec.get_qname()); + rec.serialize_annotations(); + return; + } let fire_feats = FireFeats::new(rec, fire_opts); let mut precisions = fire_feats.predict_with_xgb(model, precision_table); // FIRE produces precisions in MSP-iteration (BAM) order. Convert to @@ -102,6 +121,7 @@ pub fn add_fire_to_bam(fire_opts: &mut FireOptions) -> Result<(), anyhow::Error> let chunk: Vec = chunk.collect(); let feats: Vec = chunk .par_iter() + .filter(|r| has_m6a(r)) .map(|r| FireFeats::new(r, fire_opts)) .collect(); feats.iter().for_each(|f| { diff --git a/src/subcommands/footprint.rs b/src/subcommands/footprint.rs index a277b6fda..170a6ae1b 100644 --- a/src/subcommands/footprint.rs +++ b/src/subcommands/footprint.rs @@ -127,8 +127,12 @@ impl<'a> Footprint<'a> { pub fn new(motif: &'a ReferenceMotif, in_fibers: &'a Vec) -> Self { let mut fibers = vec![]; for fiber in in_fibers { - // add if fiber spans the footprint - if motif.spans(fiber.record.reference_start(), fiber.record.reference_end()) { + // add if fiber spans the footprint; a full-read-frame record has + // no m6A, so it was never measured and must not count as a + // fully footprinted fiber + if !fiber.is_full_read_frame() + && motif.spans(fiber.record.reference_start(), fiber.record.reference_end()) + { fibers.push(fiber); } } diff --git a/src/subcommands/predict_m6a.rs b/src/subcommands/predict_m6a.rs index acfa276a5..40b37018e 100644 --- a/src/subcommands/predict_m6a.rs +++ b/src/subcommands/predict_m6a.rs @@ -250,6 +250,15 @@ where && t.name != ma_io::MSP_TYPE && t.name != ma_io::FIRE_TYPE }); + if ma_io::model_is_full_read_frame(&annot, record) { + // Full-read tags cannot be re-derived without kinetics: + // leave an honest NotCallable record in the SEQ frame. + annot.annotation_types.clear(); + annot.set_aligned_blocks_raw( + molecular_annotation::AlignedBlocks::from_record(record), + record.is_reverse(), + ); + } // Sync the frame or the marker reads back as Untagged. annot.read_length = record.seq_len() as u32; ma_io::set_fiberseq_callable( diff --git a/src/subcommands/qc.rs b/src/subcommands/qc.rs index 5e04e6d3a..237370d5c 100644 --- a/src/subcommands/qc.rs +++ b/src/subcommands/qc.rs @@ -121,6 +121,9 @@ pub struct QcStats<'a> { // reads whose tag was stale (read_length mismatch); folded into // Untagged, tracked for a distinct warning stale_tags: i64, + // hard-clipped reads whose tags are in the full-read frame: nuc/msp + // kept, m6A dropped, counted as NotCallable; tracked for a warning + full_frame_reads: i64, // phasing information phased_reads: HashMap, phased_bp: HashMap, @@ -156,6 +159,7 @@ impl<'a> QcStats<'a> { .map(|k| (k, Counts::default())) .collect(), stale_tags: 0, + full_frame_reads: 0, qc_opts, phased_reads: HashMap::new(), phased_bp: HashMap::new(), @@ -187,6 +191,9 @@ impl<'a> QcStats<'a> { { self.stale_tags += 1; } + if fiber.is_full_read_frame() { + self.full_frame_reads += 1; + } // add auto-correlation of m6a self.add_m6a_starts_for_acf(fiber, passes); @@ -221,7 +228,9 @@ impl<'a> QcStats<'a> { // add the m6a to the working queue let mut m6a_vec: Vec = vec![0.0; fiber.record.seq_len()]; for m6a in fiber.m6a().starts().iter() { - m6a_vec[*m6a as usize] = 1.0; + if let Some(v) = m6a_vec.get_mut(*m6a as usize) { + *v = 1.0; + } } let elem = AcfRead { m6a: m6a_vec, @@ -264,7 +273,14 @@ impl<'a> QcStats<'a> { // have zero nucleosomes (the callable state needs m6A and MSPs, and the // nuc view is post-pruning), so the filtered side skips those // reads rather than admit an inf key. - let read_length = fiber.frame_length() as f32 / nuc.len() as f32; + // On a full-read-frame record the nucleosomes span the whole read, + // so divide that length, not SEQ. + let frame_len = if fiber.is_full_read_frame() { + fiber.annotations.read_length as f32 + } else { + fiber.frame_length() as f32 + }; + let read_length = frame_len / nuc.len() as f32; bump( &mut self.read_length_per_nuc, ordered_float_10k_round(read_length), @@ -550,6 +566,15 @@ pub fn run_qc(opts: &mut QcOpts) -> Result<(), anyhow::Error> { stats.stale_tags ); } + if stats.full_frame_reads > 0 { + log::warn!( + "{} reads are hard-clipped alignments whose nuc/msp tags are in the frame of the \ + full-length read. Their nuc/msp are counted, but their m6A (MM/ML) was dropped, so \ + they are NotCallable and never enter count_filtered. See the warning above \ + for how to realign.", + stats.full_frame_reads + ); + } let mut out = bio_io::writer(&opts.out)?; stats.write(&mut out)?; stats.write_m6a_acf(&mut out)?; diff --git a/src/utils/input_bam.rs b/src/utils/input_bam.rs index 9cb38a1a5..6f902f1d5 100644 --- a/src/utils/input_bam.rs +++ b/src/utils/input_bam.rs @@ -273,7 +273,7 @@ impl FiberFilters { pub struct InputBam { /// Input BAM file. If no path is provided stdin is used. For m6A prediction, this should be a HiFi bam file with kinetics data. For other commands, this should be a bam file with m6A calls. /// - /// Fiber-seq tags describe the full read, so aligned input must be soft-clipped: pbmm2 never hard-clips; for ONT use `dorado aligner --mm2-opts "-Y"` or `minimap2 -Y -y`. Tags on hard-clipped records are dropped with a warning. + /// Fiber-seq tags describe the full read, so aligned input must be soft-clipped: pbmm2 never hard-clips; for ONT use `dorado aligner --mm2-opts "-Y"` or `minimap2 -Y -y`. On hard-clipped records the nucleosome/MSP/FIRE tags are kept and lifted; the m6A cannot be recovered and is dropped, so such reads are not fiberseq-callable. #[clap(default_value = "-", value_hint = ValueHint::AnyPath)] pub bam: String, #[clap(flatten)] diff --git a/src/utils/ma_io.rs b/src/utils/ma_io.rs index ba5eb1c3f..308896f6e 100644 --- a/src/utils/ma_io.rs +++ b/src/utils/ma_io.rs @@ -20,6 +20,11 @@ //! (NotCallable); only never-processed reads have none. Non-default //! minimums name the annotation in the AN tag (e.g. "m20a10"). //! +//! Hard-clipped records whose tags describe the full-length read (an aligner +//! copied the primary's tags onto a supplementary; see [`record_frame`]) keep +//! their nuc/msp/fire in that frame, lifted with the leading hard clip, and +//! lose their MM/ML. They are always NotCallable: they have no m6A. +//! //! `m6a` and `cpg` types may appear *in memory* on a [`MolecularAnnotations`] //! populated by the library's MM/ML parser. Their on-disk source of truth //! is `MM`/`ML`; they carry `Encoding::MmMl` (set at construction, whether read @@ -27,7 +32,10 @@ //! library serializes them into MM/ML rather than the MA tag set. use anyhow::{bail, Result}; -use molecular_annotation::{ma_family_tags, Encoding, MolecularAnnotations, QualitySpec, Strand}; +use molecular_annotation::{ + hard_clips, ma_family_tags, query_span, AlignedBlocks, Encoding, MolecularAnnotations, + QualitySpec, Strand, +}; use rust_htslib::bam::{self, record::Aux}; /// Annotation type names used by fibertools-rs. @@ -49,54 +57,114 @@ type MspInput<'a> = (&'a [u32], &'a [u32], Option<&'a [u8]>); /// /// Delegates to the library's combined MA-spec + MM/ML parser. If the /// library returns an empty annotation set and the record carries legacy -/// `ns`/`nl`/`as`/`al`/`aq` tags, falls back to `read_legacy_nuc_msp`. +/// `ns`/`nl`/`as`/`al`/`aq` tags, falls back to the legacy reader. /// Tolerant of malformed MM/ML — the library handles those internally /// without panicking. +/// +/// The record's frame ([`record_frame`]) decides what is parsed: a stale +/// record comes back empty, a full-read-frame record keeps its MA/legacy +/// annotations under the full read length (lifted with the leading hard +/// clip) and never parses MM/ML, and everything else parses as before. pub fn read_record(record: &bam::Record) -> Result { - // A record whose tags describe another read is untagged, and parsing - // its MM/ML would only produce truncation noise: decide before parsing. - if let Some(why) = record_frame_reason(record) { - let mut annot = MolecularAnnotations::new(0); - annot.set_aligned_blocks_raw( - molecular_annotation::AlignedBlocks::from_record(record), - record.is_reverse(), - ); - drop_stale_frame(&mut annot, record, why); - return Ok(annot); - } - let mut annot = MolecularAnnotations::from_record(record); - // If MA tag is absent, also ingest legacy nuc/msp tags. The library - // already populates basemod types (m6a/cpg) from MM/ML, so we merge - // legacy-derived nuc/msp into whatever the library produced rather - // than gating on `annotation_types.is_empty()` (which would skip the - // legacy fallback whenever MM/ML is present). - let has_ma = ma_family_tags(record).is_some(); - if !has_ma { - // Provenance: the gates are type-checked (`has_legacy_nuc_msp`, - // `has_legacy_fibertig`), so a foreign tool reusing these two-letter - // names with a different aux type is never parsed as fibertools data. - // The write path (`strip_consumed_legacy_tags`) removes legacy tags - // under pair-level gates mirroring `read_legacy_nuc_msp`'s - // consumption — i.e. only what this reader ingested — so keep the - // two in sync. - if has_legacy_nuc_msp(record) { - merge_missing_types(&mut annot, read_legacy_nuc_msp(record)?); + let mut annot = match record_frame(record) { + // A record whose tags describe another read is untagged, and parsing + // its MM/ML would only produce truncation noise: decide before parsing. + Frame::Stale(why) => { + let mut annot = MolecularAnnotations::new(0); + drop_stale_frame(&mut annot, record, why); + return Ok(annot); } - // Legacy fibertig (`fs`/`fl`/`fa`): the pre-MA fibertig wire format, - // dropped from the writer in favour of the MA-spec `AN` tag. Kept - // readable so older fibertig BAMs stay consumable by `extract`. - if has_legacy_fibertig(record) { - merge_missing_types(&mut annot, read_legacy_fibertig(record)?); + Frame::FullRead { + h_lead, + read_length, + } => { + let annot = if ma_family_tags(record).is_some() { + // The library sets the query offset from the MA read length + // and skips MM/ML itself. + MolecularAnnotations::from_record(record) + } else { + // Legacy arrays record no frame, and every producer wrote + // them on the full read: build that frame directly so MM/ML + // are never decoded against the wrong SEQ. + let mut annot = MolecularAnnotations::new(read_length); + annot.set_aligned_blocks_raw( + AlignedBlocks::from_record(record).with_query_offset(h_lead), + record.is_reverse(), + ); + read_legacy_nuc_msp_into(record, &mut annot)?; + read_legacy_fibertig_into(record, &mut annot)?; + annot + }; + annot } - } - // Backstop on the parsed model: a read length that disagrees with SEQ, - // or any annotation ending past it. + Frame::Seq => { + let mut annot = MolecularAnnotations::from_record(record); + // The MA tag vouches for the frame, but an MN tag that disagrees + // with SEQ says the base mods were copied from a longer read: keep + // the calls, drop only the m6A/CpG (the writer strips MM/ML/MN). + if mn_disagrees(record) { + annot.annotation_types.retain(|t| !t.is_mm_ml()); + } + // If MA tag is absent, also ingest legacy nuc/msp tags. The library + // already populates basemod types (m6a/cpg) from MM/ML, so we add + // legacy-derived nuc/msp to whatever the library produced rather + // than gating on `annotation_types.is_empty()` (which would skip + // the legacy fallback whenever MM/ML is present). + if ma_family_tags(record).is_none() { + // Provenance: the gates are type-checked (`has_legacy_nuc_msp`, + // `has_legacy_fibertig`), so a foreign tool reusing these + // two-letter names with a different aux type is never parsed + // as fibertools data. The write path + // (`strip_consumed_legacy_tags`) removes legacy tags under + // pair-level gates mirroring `read_legacy_nuc_msp_into`'s + // consumption, i.e. only what this reader ingested, so keep + // the two in sync. + if has_legacy_nuc_msp(record) { + read_legacy_nuc_msp_into(record, &mut annot)?; + } + // Legacy fibertig (`fs`/`fl`/`fa`): the pre-MA fibertig wire + // format, dropped from the writer in favour of the MA-spec + // `AN` tag. Kept readable so older fibertig BAMs stay + // consumable by `extract`. + if has_legacy_fibertig(record) { + read_legacy_fibertig_into(record, &mut annot)?; + } + } + annot + } + }; + // Backstop on the parsed model: a read length that disagrees with the + // frame, or any annotation ending past it. if let Some(why) = model_frame_reason(&annot, record) { drop_stale_frame(&mut annot, record, why); + } else if model_is_full_read_frame(&annot, record) { + // Warn and count only when this pass really drops m6A. The writer + // strips MM/ML, so a second run over ft's own output stays quiet. + if matches!(record.aux(b"MM"), Ok(Aux::String(_))) { + note_full_read_frame(record); + } else { + log::debug!( + "full-read frame for {} (no m6A to drop)", + String::from_utf8_lossy(record.qname()) + ); + } } Ok(annot) } +/// True when an MN tag is present and names a different length than SEQ: +/// the MM/ML next to it were written for another read. +fn mn_disagrees(record: &bam::Record) -> bool { + let seq_len = record.seq_len(); + seq_len > 0 + && matches!(record.aux(b"MM"), Ok(Aux::String(_))) + && record + .aux(b"MN") + .ok() + .and_then(aux_as_usize) + .is_some_and(|mn| mn != seq_len) +} + /// How many stale-frame records get a WARN before the rest drop to DEBUG. /// ONT BAMs can hold thousands of hard-clipped supplementary reads. A total /// is printed at exit by [`report_stale_frames`]. @@ -151,12 +219,52 @@ fn drop_stale_frame(annot: &mut MolecularAnnotations, record: &bam::Record, why: annot.annotation_types.clear(); // An untagged read's frame is its SEQ, or its CIGAR query span without SEQ, // so the writers persist a clean `Ma:Z:` instead of the stale length. - let seq_len = record.seq_len(); - annot.read_length = if seq_len > 0 { - seq_len as u32 + // The liftover follows the frame (no query offset). + annot.read_length = query_span(record); + annot.set_aligned_blocks_raw(AlignedBlocks::from_record(record), record.is_reverse()); +} + +/// Records whose Fiber-seq tags describe the full-length read while they +/// hold a hard-clipped part of it (`Frame::FullRead`). +static FULL_FRAMES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// What happens to the base mods of a full-read-frame record. Part of the +/// once-per-run warning, so a test can look for it. +pub const FULL_FRAME_M6A_DROPPED: &str = + "their m6A (MM/ML) describes bases this record does not carry and was dropped"; + +/// Warn once per run that a record is in the full-read frame; later records +/// are logged at debug level and totalled at exit by [`report_full_frames`]. +fn note_full_read_frame(record: &bam::Record) { + use std::sync::atomic::Ordering; + let n = FULL_FRAMES.fetch_add(1, Ordering::Relaxed); + let qname = String::from_utf8_lossy(record.qname()); + if n == 0 { + log::warn!( + "some hard-clipped records carry Fiber-seq tags computed on the full-length read \ + (supplementary alignments; the aligner copied them from the primary). Their \ + nucleosome/MSP/FIRE calls are kept and lifted with the hard-clip offset; \ + {FULL_FRAME_M6A_DROPPED}, so these reads are not fiberseq-callable. \ + {HARD_CLIP_REMEDY}\n\ + full-read frame for {qname}" + ); } else { - cigar_query_len(record) - }; + log::debug!("full-read frame for {qname}"); + } +} + +/// Log the number of full-read-frame records. Called once at exit by +/// `main`, next to [`report_stale_frames`]. +pub fn report_full_frames() { + let n = FULL_FRAMES.load(std::sync::atomic::Ordering::Relaxed); + if n > 0 { + let records = if n == 1 { "record" } else { "records" }; + log::warn!( + "kept nucleosome/MSP calls on {n} hard-clipped {records} whose Fiber-seq tags \ + describe the full-length read; they have no m6A and are not fiberseq-callable. \ + {HARD_CLIP_REMEDY}" + ); + } } /// Log the number of records whose annotations were dropped for a stale @@ -172,88 +280,150 @@ pub fn report_stale_frames() { } } -/// Query bases the CIGAR consumes, hard clips excluded. -fn cigar_query_len(record: &bam::Record) -> u32 { - use rust_htslib::bam::record::Cigar; - record - .cigar() - .iter() - .map(|c| match c { - Cigar::Match(l) - | Cigar::Ins(l) - | Cigar::SoftClip(l) - | Cigar::Equal(l) - | Cigar::Diff(l) => *l, - _ => 0, - }) - .sum() +/// True when the model is in the full-read frame of a hard-clipped record: +/// SEQ is present, the record has hard clips, and the model's read length is +/// SEQ plus both clips. Such a model has no m6A (the reader never parses +/// MM/ML in this frame) and its query coordinates run past SEQ; the lift +/// carries the leading hard clip as `annot.query_offset()`. +pub(crate) fn model_is_full_read_frame(annot: &MolecularAnnotations, record: &bam::Record) -> bool { + let span = query_span(record) as usize; + if span == 0 { + return false; + } + let (lead, trail) = hard_clips(record); + lead + trail > 0 && annot.read_length as usize == span + lead as usize + trail as usize +} + +/// True when a present SEQ disagrees with the model's frame: the recorded +/// read length must equal SEQ, or SEQ plus the hard clips for a full-read +/// frame (something else rewrote the read after tagging). SEQ-less records +/// are never stale: the MA read length is the frame. +pub(crate) fn frame_is_stale(annot: &MolecularAnnotations, record: &bam::Record) -> bool { + let seq_len = record.seq_len(); + seq_len > 0 && annot.read_length as usize != seq_len && !model_is_full_read_frame(annot, record) } -/// True when a present SEQ disagrees with the recorded read length -/// (something rewrote the read after tagging). SEQ-less records are never -/// stale: the MA read length is the frame. -pub(crate) fn read_length_is_stale(read_length: u32, seq_len: usize) -> bool { - seq_len > 0 && read_length as usize != seq_len +/// Which read a record's Fiber-seq tags describe; see [`record_frame`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Frame { + /// The tags describe SEQ (unclipped, soft-clipped, SEQ-less, or computed + /// after clipping). + Seq, + /// The tags describe the full-length read of which SEQ is a hard-clipped + /// part. `h_lead` is the leading hard clip (BAM/CIGAR orientation, the + /// same end on both strands), `read_length` the full read's length. + FullRead { h_lead: u32, read_length: u32 }, + /// The tags describe some other read: drop them. + Stale(String), } -/// Signals, read straight off the record, that its Fiber-seq tags describe a -/// different read than its SEQ. Each tag family has its own frame signal: -/// - MA: the tag's own read length. A hard-clipped record whose tags were -/// computed after clipping has `read_length == seq_len` and is fine. -/// - legacy `ns`/`nl`/`as`/`al` and `fs`/`fl`: no frame is recorded and no -/// producer writes them after clipping, so any hard clip means stale; so -/// does a missing SEQ, since nothing then anchors them. -/// - MM/ML: the SAM `MN` tag when present (the spec's frame for exactly this -/// case), else hard clips. +/// Which read a record's Fiber-seq tags describe, from signals read straight +/// off the record. Each tag family has its own frame signal: +/// - MA: the tag's own read length. Equal to SEQ (a hard-clipped record whose +/// tags were computed after clipping included): `Seq`. Equal to SEQ plus +/// the hard clips: `FullRead`, the aligner copied the full-length read's +/// tags onto this clipped part of it. Anything else: `Stale`. +/// - legacy `ns`/`nl`/`as`/`al` and fibertig `fs`/`fl`: no frame is recorded +/// and every producer wrote them on the full read, so any hard clip means +/// `FullRead` with `read_length = seq_len + H_lead + H_trail`; a missing +/// SEQ is `Stale`, since nothing then anchors them. +/// - MM/ML (with no MA or legacy tags deciding first): the SAM `MN` tag when +/// present (the spec's frame for exactly this case), else hard clips. +/// +/// In the full-read frame the MA-family coordinates are still right (they +/// are molecular coordinates of the full read; only the lift needs the +/// leading hard clip), but MM/ML are SEQ-relative and cannot be recovered: +/// the reader drops them and the writer strips them. /// /// SEQ-less MA records are never stale: the MA read length is the frame. /// Both the reader ([`read_record`]) and the writer ([`write_record`]) use /// this, so a stale record is cleared on the way in and cleaned on the way /// out. -pub(crate) fn record_frame_reason(record: &bam::Record) -> Option { +pub(crate) fn record_frame(record: &bam::Record) -> Frame { let seq_len = record.seq_len(); - let cigar = record.cigar(); - let hard_clipped = cigar.leading_hardclips() > 0 || cigar.trailing_hardclips() > 0; + let (lead, trail) = hard_clips(record); + let hard_clipped = lead + trail > 0; + // The bases this record holds: SEQ, or the CIGAR query span when SEQ was + // dropped. A hard-clipped record's frame is judged against that span so + // a SEQ-less supplementary still gets the offset lift. + let span = query_span(record) as usize; + let full = span + lead as usize + trail as usize; if let Some((ma, _, _)) = ma_family_tags(record) { if let Some(read_length) = ma.split(';').next().and_then(|s| s.parse::().ok()) { + if hard_clipped && span > 0 && read_length == full { + return Frame::FullRead { + h_lead: lead, + read_length: read_length as u32, + }; + } if seq_len > 0 && read_length != seq_len { - return Some(format!( + if hard_clipped && read_length == full { + return Frame::FullRead { + h_lead: lead, + read_length: read_length as u32, + }; + } + if hard_clipped { + return Frame::Stale(format!( + "MA read length {read_length} matches neither the {seq_len} bp sequence \ + nor the {full} bp read it was hard-clipped from" + )); + } + return Frame::Stale(format!( "MA read length {read_length} does not match the {seq_len} bp sequence" )); } // The MA tag was written for this SEQ, so it vouches for the // MM/ML next to it too: fibertools' own writers emit MA and // MM/ML together, and a hard-clipped record it produced is fine. - return None; + return Frame::Seq; } } else if has_legacy_nuc_msp(record) || has_legacy_fibertig(record) { - if hard_clipped { - return Some("legacy nuc/msp tags on a hard-clipped alignment".to_string()); - } if seq_len == 0 { - return Some("legacy nuc/msp tags on a record without SEQ".to_string()); + return Frame::Stale("legacy nuc/msp tags on a record without SEQ".to_string()); + } + if hard_clipped { + // Any MM/ML next to them are SEQ-relative copies from the full + // read, dropped with the frame; they are not a stale signal. + return Frame::FullRead { + h_lead: lead, + read_length: full as u32, + }; } } if matches!(record.aux(b"MM"), Ok(Aux::String(_))) { if seq_len == 0 { // Positions are implicit in SEQ; without it there is nothing to // decode against (minimap2 -y writes SEQ-less secondaries). - return Some("MM/ML on a record without SEQ".to_string()); + return Frame::Stale("MM/ML on a record without SEQ".to_string()); } match record.aux(b"MN").ok().and_then(aux_as_usize) { Some(mn) => { - if seq_len > 0 && mn != seq_len { - return Some(format!("MN {mn} does not match the {seq_len} bp sequence")); + if mn != seq_len { + return Frame::Stale(format!( + "MN {mn} does not match the {seq_len} bp sequence" + )); } } None => { if hard_clipped { - return Some("MM/ML on a hard-clipped alignment without an MN tag".to_string()); + return Frame::Stale( + "MM/ML on a hard-clipped alignment without an MN tag".to_string(), + ); } } } } - None + Frame::Seq +} + +/// Why a record's tags describe a different read than its SEQ, or `None` +/// when they describe SEQ or the full read it was hard-clipped from. +pub(crate) fn record_frame_reason(record: &bam::Record) -> Option { + match record_frame(record) { + Frame::Stale(why) => Some(why), + _ => None, + } } fn aux_as_usize(aux: Aux) -> Option { @@ -268,34 +438,32 @@ fn aux_as_usize(aux: Aux) -> Option { } } -/// The parsed model disagrees with SEQ: a read length that does not match, -/// or an annotation ending past it. Backstop behind [`record_frame_reason`] -/// for tags that carry no frame signal of their own. +/// The parsed model disagrees with its frame: a read length that matches +/// neither SEQ nor the full read SEQ was hard-clipped from, or an annotation +/// ending past the frame. Backstop behind [`record_frame`] for tags that +/// carry no frame signal of their own. fn model_frame_reason(annot: &MolecularAnnotations, record: &bam::Record) -> Option { let seq_len = record.seq_len(); if seq_len == 0 { return None; } - if read_length_is_stale(annot.read_length, seq_len) { + if frame_is_stale(annot, record) { return Some(format!( "MA read length {} does not match the {seq_len} bp sequence", annot.read_length )); } + // SEQ, or the full read in the full-read frame. + let frame = annot.read_length as usize; annot .annotation_types .iter() .find(|t| { t.annotations .iter() - .any(|a| a.start as usize + a.length as usize > seq_len) - }) - .map(|t| { - format!( - "{} coordinates extend past the {seq_len} bp sequence", - t.name - ) + .any(|a| a.start as usize + a.length as usize > frame) }) + .map(|t| format!("{} coordinates extend past the {frame} bp read", t.name)) } /// Why a record's annotations do not fit its SEQ, or `None` when they do: @@ -312,25 +480,45 @@ pub(crate) fn stale_frame_reason( /// Runs right after parsing, before any consumer-side pruning. Derives /// when the tag is absent (backfill) or the minimums are non-default /// (recalculation); otherwise the on-disk tag stands. +/// +/// A full-read-frame record (hard clips, tags from the full read) has no +/// m6A: the reader dropped its MM/ML. FIRE cannot score it, so it is +/// NotCallable whatever its on-disk tag or the minimums say. That is the +/// only "no m6A" this function knows about; m6A is never inspected, so a +/// read whose MM/ML were stripped on purpose (ft strip-basemods) keeps the +/// verdict from calling time. pub fn sync_fiberseq_callable( annot: &mut MolecularAnnotations, record: &bam::Record, filters: &crate::utils::input_bam::FiberFilters, ) { + let (min_msp, min_ave) = filters.callable_minimums(); + if model_is_full_read_frame(annot, record) { + // No m6A means not callable. Without nuc/msp there is nothing to + // judge either, so a never-called read gets no marker, but a copied + // Callable span must not survive the frame it no longer describes. + if has_calls(annot) || annot.get_type(FIBERSEQ_CALLABLE_TYPE).is_some() { + set_fiberseq_callable(annot, None, callable_minimums_name(min_msp, min_ave)); + } + return; + } let needed = filters.callable_minimums_are_custom() || annot.get_type(FIBERSEQ_CALLABLE_TYPE).is_none(); if needed && can_derive_callable(annot, record) { - let (min_msp, min_ave) = filters.callable_minimums(); derive_fiberseq_callable(annot, min_msp, min_ave); } } +/// True when calling ran: nuc or msp present. +fn has_calls(annot: &MolecularAnnotations) -> bool { + annot.get_type(NUC_TYPE).is_some() || annot.get_type(MSP_TYPE).is_some() +} + /// True when the callable state can be derived: calling ran (nuc or msp /// present) and the frame is not stale. Derivation is pure MA-tag /// arithmetic, so SEQ-less records derive fine. fn can_derive_callable(annot: &MolecularAnnotations, record: &bam::Record) -> bool { - !read_length_is_stale(annot.read_length, record.seq_len()) - && (annot.get_type(NUC_TYPE).is_some() || annot.get_type(MSP_TYPE).is_some()) + !frame_is_stale(annot, record) && has_calls(annot) } /// AN name recording non-default minimums, e.g. "m20a10". `None` for the @@ -366,10 +554,11 @@ pub fn set_fiberseq_callable( /// Derive the callable span and state from the nuc/MSP annotations; the /// single source of truth for every writer. The span is the extent of the -/// surviving calls; callable requires >= `min_msp` MSPs with mean length -/// >= `min_ave_msp_size`. m6A is never inspected: the caller emits no MSP -/// without m6A, and MA-only derivation is what lets SEQ-less records -/// derive. +/// surviving calls; callable requires at least `min_msp` MSPs with a mean +/// length of at least `min_ave_msp_size`. m6A is never inspected: the caller +/// emits no MSP without m6A, and MA-only derivation is what lets SEQ-less +/// records derive. A full-read-frame record (no m6A) is forced NotCallable +/// by [`sync_fiberseq_callable`], not here. pub fn derive_fiberseq_callable( annot: &mut MolecularAnnotations, min_msp: usize, @@ -404,23 +593,6 @@ pub fn derive_fiberseq_callable( ); } -/// Merge every annotation type from `src` into `dst`, skipping any type whose -/// name already exists on `dst`. Lets the legacy-tag fallbacks layer their -/// annotations on top of whatever the MA/MM/ML library parser already produced -/// rather than clobbering it (e.g. legacy nuc/msp alongside library-parsed -/// base mods). -fn merge_missing_types(dst: &mut MolecularAnnotations, src: MolecularAnnotations) { - for t in src.annotation_types.into_iter() { - if dst.get_type(&t.name).is_some() { - continue; - } - let new_t = dst.add_annotation_type(&t.name, t.quality_spec.clone(), t.encoding); - for a in t.annotations.into_iter() { - new_t.add_shared(a.start, a.length, a.strand, a.qualities, a.name); - } - } -} - /// Writes MA-family tags (MA/AQ/AN) to a BAM record, **preserving the /// record's existing MM/ML bytes**. /// @@ -458,20 +630,37 @@ fn merge_missing_types(dst: &mut MolecularAnnotations, src: MolecularAnnotations /// Producers that create or modify base mods must instead call /// [`write_record_with_basemods`], which canonically re-emits MM/ML. pub fn write_record(record: &mut bam::Record, annot: &MolecularAnnotations) { - if record_frame_reason(record).is_some() { + match record_frame(record) { // The reader cleared this record's annotations (drop_stale_frame); // leave no stale tag behind for another tool to trust. - strip_all_fiber_tags(record); - } else { - strip_consumed_legacy_tags(record); + Frame::Stale(_) => strip_all_fiber_tags(record), + Frame::FullRead { .. } => { + // The MA tag is rewritten from the model below, in the full + // read's frame. The SEQ-relative base mods describe bases this + // record does not carry: the reader dropped them, so leave none + // behind for another tool to trust. + for tag in [b"MM", b"ML", b"MN"] { + record.remove_aux(tag).ok(); + } + strip_consumed_legacy_tags(record); + } + Frame::Seq => { + if mn_disagrees(record) { + // The reader dropped these base mods (see read_record). + for tag in [b"MM", b"ML", b"MN"] { + record.remove_aux(tag).ok(); + } + } + strip_consumed_legacy_tags(record) + } } annot.to_record(record); } /// Every Fiber-seq tag fibertools knows how to read: legacy nuc/msp/fibertig /// arrays, MM/ML/MN base mods, and both spellings of the MA family. Used only -/// for records whose frame is stale (`record_frame_reason`), where none of -/// them describe this SEQ. +/// for records whose frame is stale (`record_frame`), where none of them +/// describe this SEQ. fn strip_all_fiber_tags(record: &mut bam::Record) { for tag in [ b"ns", b"nl", b"as", b"al", b"aq", b"fs", b"fl", b"fa", b"MM", b"ML", b"MN", b"Ma", b"Aq", @@ -486,7 +675,7 @@ fn strip_all_fiber_tags(record: &mut bam::Record) { /// written — they are superseded by the MA-family tags (v0.9 replace /// semantics; otherwise legacy readers silently see stale calls forever). /// -/// The gates mirror [`read_legacy_nuc_msp`]'s consumption at PAIR level +/// The gates mirror [`read_legacy_nuc_msp_into`]'s consumption at PAIR level /// (ns+nl, as+al, aq only inside the msp pair, fa only with fs+fl), which is /// what makes this provenance-safe: a tag is only removed when the reader /// ingested it. Records that already carry an MA-family main tag were not @@ -504,10 +693,13 @@ fn strip_consumed_legacy_tags(record: &mut bam::Record) { // writing an empty model — nothing was consumed, so nothing may be // removed. Gating on the same parse keeps strip and read consumption // identical by construction. - if read_legacy_nuc_msp(record).is_err() || read_legacy_fibertig(record).is_err() { + let mut scratch = MolecularAnnotations::new(0); + if read_legacy_nuc_msp_into(record, &mut scratch).is_err() + || read_legacy_fibertig_into(record, &mut scratch).is_err() + { return; } - // Pair-level gates mirroring read_legacy_nuc_msp: ns+nl only as a pair, + // Pair-level gates mirroring read_legacy_nuc_msp_into: ns+nl only as a pair, // as+al only as a pair, aq only inside a valid msp pair (its count is // validated by the parse above). A lone or orphan tag was never // consumed and so is never removed. @@ -523,7 +715,7 @@ fn strip_consumed_legacy_tags(record: &mut bam::Record) { } } // fibertig: fs+fl as a pair; fa is only consumed (and so only stripped) - // when the pair is non-empty, mirroring read_legacy_fibertig's early + // when the pair is non-empty, mirroring read_legacy_fibertig_into's early // return on empty fs. let fs = u32_array(record, b"fs"); if fs.is_some() && u32_array(record, b"fl").is_some() { @@ -595,8 +787,11 @@ fn read_ma_tags(record: &bam::Record) -> Result> { }; let mut annot = MolecularAnnotations::from_tags(&ma, aq.as_deref(), an.as_deref()) .map_err(|e| anyhow::anyhow!("MA tag parse error: {e}"))?; + // Same frame rule as the library's from_record: a full-read MA tag on a + // hard-clipped record lifts with the leading hard clip. + let offset = molecular_annotation::full_read_query_offset(annot.read_length, record); annot.set_aligned_blocks_raw( - molecular_annotation::AlignedBlocks::from_record(record), + AlignedBlocks::from_record(record).with_query_offset(offset.unwrap_or(0)), record.is_reverse(), ); Ok(Some(annot)) @@ -604,7 +799,13 @@ fn read_ma_tags(record: &bam::Record) -> Result> { fn read_legacy_nuc_msp(record: &bam::Record) -> Result { let mut annot = MolecularAnnotations::from_record(record); + read_legacy_nuc_msp_into(record, &mut annot)?; + Ok(annot) +} +/// Add the legacy `ns`/`nl`/`as`/`al`/`aq` tags to `annot` as nuc/msp/fire +/// types. The model's frame (read length, aligned blocks) is the caller's. +fn read_legacy_nuc_msp_into(record: &bam::Record, annot: &mut MolecularAnnotations) -> Result<()> { let ns = u32_array(record, b"ns"); let nl = u32_array(record, b"nl"); let a_starts = u32_array(record, b"as"); @@ -673,7 +874,7 @@ fn read_legacy_nuc_msp(record: &bam::Record) -> Result { } } - Ok(annot) + Ok(()) } /// Read the legacy fibertig `fs`/`fl`/`fa` tags into a [`FIBERTIG_TYPE`] @@ -686,13 +887,11 @@ fn read_legacy_nuc_msp(record: &bam::Record) -> Result { /// those older BAMs consumable. Mirrors the in-memory shape the MA path /// produces (`Strand::Forward`, no quality, `Encoding::Ma`) so downstream /// liftover to reference coordinates is identical either way. -fn read_legacy_fibertig(record: &bam::Record) -> Result { +fn read_legacy_fibertig_into(record: &bam::Record, annot: &mut MolecularAnnotations) -> Result<()> { use crate::utils::fibertig::FIBERTIG_TYPE; - let mut annot = MolecularAnnotations::from_record(record); - let (Some(fs), Some(fl)) = (u32_array(record, b"fs"), u32_array(record, b"fl")) else { - return Ok(annot); + return Ok(()); }; if fs.len() != fl.len() { bail!( @@ -702,7 +901,7 @@ fn read_legacy_fibertig(record: &bam::Record) -> Result { ); } if fs.is_empty() { - return Ok(annot); + return Ok(()); } // `fa` is optional; when present it must have one `|`-separated segment per @@ -730,7 +929,7 @@ fn read_legacy_fibertig(record: &bam::Record) -> Result { let name = names.as_ref().and_then(|v| v[i].clone()); t.add(*s, *l, Strand::Forward, vec![], name); } - Ok(annot) + Ok(()) } /// Convenience for callers that still operate on raw `i64` arrays of @@ -1280,15 +1479,28 @@ mod tests { assert_eq!(nuc_starts(&r), vec![9], "MA text is 1-based"); } - // Legacy tags carry no frame: any hard clip is stale even when every - // coordinate fits, a soft clip is not, and no SEQ is. + // Legacy tags carry no frame and were always written on the full read: + // a hard clip puts them in the full-read frame, a soft clip does not, + // and no SEQ is stale. #[test] fn stale_frame_legacy_uses_hard_clips() { let seq = b"ACGT".repeat(50); let mut fits = synth_aligned(&seq, "30H200M", 2048); legacy(&mut fits, &[10, 100], &[20, 20]); - assert!(record_frame_reason(&fits).is_some()); - assert!(nuc_starts(&fits).is_empty()); + assert_eq!( + record_frame(&fits), + Frame::FullRead { + h_lead: 30, + read_length: 230 + } + ); + assert!(record_frame_reason(&fits).is_none()); + assert_eq!( + nuc_starts(&fits), + vec![10, 100], + "legacy coords are full-read molecular coords" + ); + assert_eq!(read_record(&fits).unwrap().read_length, 230); let mut soft = synth_aligned(&seq, "30S170M", 2048); legacy(&mut soft, &[10, 100], &[20, 20]); @@ -1396,15 +1608,16 @@ mod tests { #[test] fn write_record_strips_every_tag_of_a_stale_record() { let seq = b"ACGT".repeat(50); - let mut r = synth_aligned(&seq, "30H200M", 2048); + let mut r = synth_aligned(&seq, "200M", 0); legacy(&mut r, &[10], &[20]); + r.push_aux(b"Ma", Aux::String("300;nuc.:11-20")).unwrap(); r.push_aux(b"MM", Aux::String("A+a.,0;")).unwrap(); r.push_aux(b"ML", Aux::ArrayU8((&[200u8][..]).into())) .unwrap(); let annot = read_record(&r).unwrap(); assert!(annot.annotation_types.is_empty()); write_record(&mut r, &annot); - for tag in [b"ns", b"nl", b"MM", b"ML"] { + for tag in [b"ns", b"nl", b"MM", b"ML", b"MN"] { assert!( r.aux(tag).is_err(), "{} survived", @@ -1417,4 +1630,281 @@ mod tests { "Ma = {ma:?}" ); } + + // MA read length decides the frame three ways; the offset is the leading + // hard clip on both strands. + #[test] + fn frame_rule_ma_three_way() { + let seq = b"ACGT".repeat(50); // 200 bp + let mut full = synth_aligned(&seq, "50H200M50H", 2048); + // molecular [60,100), [200,240) + full.push_aux(b"Ma", Aux::String("300;nuc.:61-40,201-40")) + .unwrap(); + assert_eq!( + record_frame(&full), + Frame::FullRead { + h_lead: 50, + read_length: 300 + } + ); + assert!(record_frame_reason(&full).is_none()); + let annot = read_record(&full).unwrap(); + assert_eq!(annot.read_length, 300); + assert_eq!(annot.query_offset(), 50); + assert_eq!(nuc_starts(&full), vec![60, 200], "tag kept as is"); + assert_eq!( + annot.get_ref_coords(NUC_TYPE).unwrap(), + vec![ + (60, 100, Some(10), Some(50)), + (200, 240, Some(150), Some(190)) + ] + ); + + let mut clipped = synth_aligned(&seq, "50H200M50H", 2048); + clipped + .push_aux(b"Ma", Aux::String("200;nuc.:11-40")) + .unwrap(); + assert_eq!(record_frame(&clipped), Frame::Seq); + let annot = read_record(&clipped).unwrap(); + assert_eq!((annot.read_length, annot.query_offset()), (200, 0)); + assert_eq!( + annot.get_ref_coords(NUC_TYPE).unwrap(), + vec![(10, 50, Some(10), Some(50))], + "clipped frame lifts as today" + ); + + let mut stale = synth_aligned(&seq, "50H200M50H", 2048); + stale + .push_aux(b"Ma", Aux::String("250;nuc.:11-40")) + .unwrap(); + assert!(matches!(record_frame(&stale), Frame::Stale(_))); + assert!(nuc_starts(&stale).is_empty()); + assert_eq!(read_record(&stale).unwrap().read_length, 200); + + // reverse strand, trailing clip: offset 0; molecular [200,240) -> BAM [60,100) + let mut rev = synth_aligned(&seq, "200M100H", 2064); + rev.push_aux(b"Ma", Aux::String("300;nuc.:201-40")).unwrap(); + let annot = read_record(&rev).unwrap(); + assert_eq!(annot.query_offset(), 0); + assert_eq!( + annot.get_ref_coords(NUC_TYPE).unwrap(), + vec![(60, 100, Some(60), Some(100))] + ); + // reverse strand, leading clip: molecular [100,140) -> BAM [160,200) -> SEQ [60,100) + let mut rev_lead = synth_aligned(&seq, "100H200M", 2064); + rev_lead + .push_aux(b"Ma", Aux::String("300;nuc.:101-40")) + .unwrap(); + let annot = read_record(&rev_lead).unwrap(); + assert_eq!(annot.query_offset(), 100); + assert_eq!( + annot.get_ref_coords(NUC_TYPE).unwrap(), + vec![(160, 200, Some(60), Some(100))] + ); + // an annotation past the full read length is still stale + let mut past = synth_aligned(&seq, "50H200M50H", 2048); + past.push_aux(b"Ma", Aux::String("300;nuc.:281-40")) + .unwrap(); + assert!(nuc_starts(&past).is_empty()); + } + + // A full-read frame keeps nuc/msp, drops m6A, and the writer strips + // MM/ML/MN. + #[test] + fn full_frame_drops_mm_ml_and_strips_on_write() { + use crate::utils::basemods::M6A_TYPE; + let seq = b"ACGT".repeat(50); + // MN of the full read (dorado copy) or of SEQ: dropped either way + for mn in [250i32, 200] { + let mut r = synth_aligned(&seq, "50H200M", 2048); + r.push_aux(b"Ma", Aux::String("250;nuc.:61-40;msp.:101-20")) + .unwrap(); + r.push_aux(b"MM", Aux::String("A+a.,0;")).unwrap(); + r.push_aux(b"ML", Aux::ArrayU8((&[200u8][..]).into())) + .unwrap(); + r.push_aux(b"MN", Aux::I32(mn)).unwrap(); + let annot = read_record(&r).unwrap(); + assert!(annot.get_type(NUC_TYPE).is_some() && annot.get_type(MSP_TYPE).is_some()); + assert!( + annot.get_type(M6A_TYPE).is_none(), + "MN {mn}: m6A must be dropped on a full-read frame" + ); + write_record(&mut r, &annot); + for tag in [b"MM", b"ML", b"MN"] { + assert!( + r.aux(tag).is_err(), + "MN {mn}: {} survived", + String::from_utf8_lossy(tag) + ); + } + let ma = r.aux(b"Ma"); + assert!( + matches!(ma, Ok(Aux::String(s)) if s.starts_with("250;") && s.contains("nuc") && s.contains("msp")), + "Ma = {ma:?}" + ); + // write_record_with_basemods on the same model writes no MM/MN either + write_record_with_basemods(&mut r, &annot); + assert!(r.aux(b"MM").is_err() && r.aux(b"MN").is_err()); + // and the rewritten record reads back in the same frame + let back = read_record(&r).unwrap(); + assert_eq!((back.read_length, back.query_offset()), (250, 50)); + assert_eq!(back.get_forward_coords(NUC_TYPE), Some(vec![(60, 100)])); + } + } + + // Legacy ns/nl on a hard clip: read_length = seq + H_lead + H_trail, + // full-read frame. + #[test] + fn legacy_on_hard_clip_is_a_full_read_frame() { + let seq = b"ACGT".repeat(50); + let mut r = synth_aligned(&seq, "30H200M", 2048); + // [10,30) before SEQ, [20,60) straddles, [100,120) inside + legacy(&mut r, &[10, 20, 100], &[20, 40, 20]); + let annot = read_record(&r).unwrap(); + assert_eq!((annot.read_length, annot.query_offset()), (230, 30)); + assert_eq!( + annot.get_ref_coords(NUC_TYPE).unwrap(), + vec![ + (10, 30, None, None), + (20, 60, Some(0), Some(30)), + (100, 120, Some(70), Some(90)) + ] + ); + write_record(&mut r, &annot); + assert!( + r.aux(b"ns").is_err() && r.aux(b"nl").is_err(), + "consumed legacy tags stripped" + ); + assert!( + matches!(r.aux(b"Ma"), Ok(Aux::String(s)) if s.starts_with("230;") && s.contains("nuc.:11-20,21-40,101-20")), + "Ma = {:?}", + r.aux(b"Ma") + ); + let mut trail = synth_aligned(&seq, "200M30H", 0); + legacy(&mut trail, &[100], &[20]); + let annot = read_record(&trail).unwrap(); + assert_eq!((annot.read_length, annot.query_offset()), (230, 0)); + assert_eq!( + annot.get_ref_coords(NUC_TYPE).unwrap(), + vec![(100, 120, Some(100), Some(120))] + ); + // molecular [100,120) -> BAM [110,130) + let mut rev = synth_aligned(&seq, "200M30H", 2064); + legacy(&mut rev, &[100], &[20]); + assert_eq!( + read_record(&rev).unwrap().get_ref_coords(NUC_TYPE).unwrap(), + vec![(110, 130, Some(110), Some(130))] + ); + } + + // No m6A on a full-read frame means NotCallable, whatever the on-disk + // span says; an unprocessed full-frame record stays Untagged; a clipped + // frame keeps its span. + #[test] + fn full_frame_records_derive_not_callable() { + use crate::fiber::{CallableState, FiberseqData}; + let seq = b"ACGT".repeat(50); + let filters = crate::utils::input_bam::FiberFilters::default(); + // 10 MSPs, mean 15, end 150 + let msp = (0..10) + .map(|i| format!("{}-15", 1 + i * 15)) + .collect::>() + .join(","); + let state = |r: &bam::Record| { + FiberseqData::new(r.clone(), None, &filters) + .callable_state() + .0 + }; + let marker = |r: &bam::Record| { + let mut annot = read_record(r).unwrap(); + sync_fiberseq_callable(&mut annot, r, &filters); + annot + .get_type(FIBERSEQ_CALLABLE_TYPE) + .map(|t| (t.annotations[0].start, t.annotations[0].length)) + }; + let mut full = synth_aligned(&seq, "50H200M", 2048); + full.push_aux( + b"Ma", + Aux::String(&format!("250;msp.:{msp};fiberseq_callable.:1-150")), + ) + .unwrap(); + assert_eq!( + marker(&full), + Some((0, 0)), + "no m6A: NotCallable marker replaces the span" + ); + assert_eq!(state(&full), CallableState::NotCallable); + let mut clipped = synth_aligned(&seq, "50H200M", 2048); + clipped + .push_aux( + b"Ma", + Aux::String(&format!("200;msp.:{msp};fiberseq_callable.:1-150")), + ) + .unwrap(); + assert_eq!(marker(&clipped), Some((0, 150))); + assert_eq!(state(&clipped), CallableState::Callable); + let mut bare = synth_aligned(&seq, "50H200M", 2048); + bare.push_aux(b"Ma", Aux::String("250")).unwrap(); + assert_eq!(marker(&bare), None); + assert_eq!(state(&bare), CallableState::Untagged); + } + + // A SEQ-less hard-clipped record with a full-read MA tag is judged by + // its CIGAR span, so it gets the offset lift like a record with SEQ. + #[test] + fn seqless_hard_clipped_ma_is_full_read_frame() { + let mut r = synth_aligned(b"", "50H200M", 2048); + r.push_aux(b"Ma", Aux::String("250;nuc.:60-40")).unwrap(); + assert_eq!( + record_frame(&r), + Frame::FullRead { + h_lead: 50, + read_length: 250 + } + ); + let annot = read_record(&r).unwrap(); + assert!(annot.get_type(NUC_TYPE).is_some()); + assert!(model_is_full_read_frame(&annot, &r)); + } + + // An MN tag that disagrees with SEQ next to an MA tag that matches it: + // the calls stay, only the base mods go, on read and on write. + #[test] + fn mn_disagreement_drops_only_base_mods() { + let seq = b"ACGT".repeat(50); + let mut r = synth_aligned(&seq, "200M", 0); + r.push_aux(b"Ma", Aux::String("200;nuc.:10-40")).unwrap(); + r.push_aux(b"MM", Aux::String("A+a.,0;")).unwrap(); + r.push_aux(b"ML", Aux::ArrayU8((&[200u8][..]).into())) + .unwrap(); + r.push_aux(b"MN", Aux::I32(500)).unwrap(); + assert_eq!(record_frame(&r), Frame::Seq); + let annot = read_record(&r).unwrap(); + assert!(annot.get_type(NUC_TYPE).is_some()); + assert!(annot.annotation_types.iter().all(|t| !t.is_mm_ml())); + write_record(&mut r, &annot); + for tag in [b"MM", b"ML", b"MN"] { + assert!( + r.aux(tag).is_err(), + "{} survived", + String::from_utf8_lossy(tag) + ); + } + assert!(r.aux(b"Ma").is_ok()); + } + + // A Callable span copied onto a full-frame record with no calls of its + // own is replaced by the NotCallable marker rather than kept. + #[test] + fn full_frame_copied_callable_span_is_replaced() { + let seq = b"ACGT".repeat(50); + let filters = crate::utils::input_bam::FiberFilters::default(); + let mut r = synth_aligned(&seq, "50H200M", 2048); + r.push_aux(b"Ma", Aux::String("250;fiberseq_callable.:1-200")) + .unwrap(); + let mut annot = read_record(&r).unwrap(); + sync_fiberseq_callable(&mut annot, &r, &filters); + let t = annot.get_type(FIBERSEQ_CALLABLE_TYPE).expect("marker kept"); + assert_eq!(t.annotations[0].length, 0, "copied Callable span survived"); + } } diff --git a/src/utils/nucleosome.rs b/src/utils/nucleosome.rs index 47eb37a13..f42de93fd 100644 --- a/src/utils/nucleosome.rs +++ b/src/utils/nucleosome.rs @@ -201,6 +201,20 @@ pub fn add_nucleosomes_to_annotations( if record.seq_len() == 0 { return; } + // A full-read-frame record (hard clips, tags from the full read) has no + // m6A to call from; re-calling would erase its full-read nuc/msp and + // rewrite the read length to SEQ. Keep the record as it is; it is + // NotCallable (sync_fiberseq_callable) and cannot be scored by FIRE. + if ma_io::model_is_full_read_frame(annot, record) { + static FULL_FRAME_LOG: std::sync::Once = std::sync::Once::new(); + FULL_FRAME_LOG.call_once(|| { + log::warn!( + "add-nucleosomes skips hard-clipped reads whose tags are in the frame of the \ + full-length read (no m6A to call from); their nuc/msp are kept as is" + ); + }); + return; + } // The annotations must describe THIS record. An inherited MA field 0 // from a differently-sized input would make the tag we are about to // write read back as stale (Untagged) forever. diff --git a/tests/data/ont_hardclip_full_frame.bam b/tests/data/ont_hardclip_full_frame.bam new file mode 100644 index 0000000000000000000000000000000000000000..329f0daa3ce0b927b3e1a46861e844b01ac7372b GIT binary patch literal 22887 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 W{{{d;LjnLB00RI3000000002WOMb=x literal 0 HcmV?d00001 diff --git a/tests/data/ont_hardclip_full_frame.bam.bai b/tests/data/ont_hardclip_full_frame.bam.bai new file mode 100644 index 0000000000000000000000000000000000000000..0b969b5bcf3f861b13e0ed5184032ba968802fa0 GIT binary patch literal 512 zcmZ>A^kmd$U|?VZVoxB!2&5Sp(pkY2gLfo|_L>BdM;C`Gf+z)&>|ho|uY3+^G~AF; I23 (bam::Record, u32) { + use fibertools_rs::utils::input_bam::FiberFilters; + use fibertools_rs::utils::ma_io::sync_fiberseq_callable; + use rust_htslib::bam::record::{Cigar, CigarString}; + let record = read_records("msp_nuc.bam").into_iter().next().unwrap(); + let mut annot = read_record(&record).unwrap(); + sync_fiberseq_callable(&mut annot, &record, &FiberFilters::default()); + let mut tagged = record.clone(); + write_record(&mut tagged, &annot); + let len = tagged.seq_len() as u32; + let seq = tagged.seq().as_bytes()[h as usize..].to_vec(); + let qual = tagged.qual()[h as usize..].to_vec(); + let cigar = CigarString(vec![Cigar::HardClip(h), Cigar::Match(len - h)]); + let mut clipped = tagged.clone(); + clipped.set(tagged.qname(), Some(&cigar), &seq, &qual); + (clipped, len) +} + +#[test] +fn full_frame_record_is_not_callable() { + use fibertools_rs::fiber::{CallableState, FiberseqData}; + use fibertools_rs::utils::input_bam::FiberFilters; + let (clipped, len) = full_frame_copy(500); + for filters in [ + FiberFilters::default(), + FiberFilters { + min_msp: Some(1), + min_ave_msp_size: Some(1), + ..FiberFilters::default() + }, + ] { + let fiber = FiberseqData::new(clipped.clone(), None, &filters); + assert!(fiber.is_full_read_frame()); + assert_eq!(fiber.callable_state(), (CallableState::NotCallable, 0, 0)); + assert!(!fiber.is_callable()); + assert!(fiber.callable_reference_range().is_none()); + assert!(fiber.m6a().is_empty(), "m6A must be dropped"); + assert!(!fiber.nuc().is_empty() && !fiber.msp().is_empty()); + assert_eq!(fiber.frame_length(), (len - 500) as usize); + assert_eq!(fiber.annotations.query_offset(), 500); + } +} + +#[test] +fn full_frame_record_without_calls_stays_untagged() { + use fibertools_rs::fiber::{CallableState, FiberseqData}; + use fibertools_rs::utils::input_bam::FiberFilters; + let (mut clipped, len) = full_frame_copy(500); + clipped.remove_aux(b"Ma").unwrap(); + clipped + .push_aux(b"Ma", Aux::String(&len.to_string())) + .unwrap(); + let fiber = FiberseqData::new(clipped, None, &FiberFilters::default()); + assert!(fiber.is_full_read_frame()); + assert_eq!(fiber.callable_state().0, CallableState::Untagged); +} diff --git a/tests/nucleosome.rs b/tests/nucleosome.rs index 95192a114..3dc6cf423 100644 --- a/tests/nucleosome.rs +++ b/tests/nucleosome.rs @@ -189,3 +189,29 @@ fn seqless_record_passes_through_untouched() { assert_eq!(t.annotations[0].start, 100); assert_eq!(t.annotations[0].length, 400); } + +/// A hard-clipped record whose tags are in the full-read frame has no m6A +/// to call from: the producer leaves it alone instead of erasing its +/// full-read msp and rewriting the read length to SEQ (#136). +#[test] +fn add_nucleosomes_skips_full_frame_records() { + use fibertools_rs::utils::ma_io::{self, MSP_TYPE}; + use rust_htslib::bam::record::{Cigar, CigarString}; + let o = fibertools_rs::cli::NucleosomeParameters::default(); + let mut r = rec(1000); + let cigar = CigarString(vec![Cigar::HardClip(200), Cigar::Match(1000)]); + r.set(b"test", Some(&cigar), &vec![b'A'; 1000], &vec![255u8; 1000]); + r.set_tid(0); + r.set_pos(0); + let mut annot = MolecularAnnotations::new(1200); + ma_io::add_msp_annotations(&mut annot, &[300], &[50], None); + ma_io::write_record(&mut r, &annot); + let mut annot = ma_io::read_record(&r).unwrap(); + assert_eq!((annot.read_length, annot.query_offset()), (1200, 200)); + add_nucleosomes_to_annotations(&r, &mut annot, &[], &o, (10, 10)); + assert_eq!(annot.read_length, 1200); + let msp = annot.get_type(MSP_TYPE).expect("msp kept"); + assert_eq!(msp.annotations.len(), 1); + assert_eq!(msp.annotations[0].start, 300); + assert!(annot.get_type(FIBERSEQ_CALLABLE_TYPE).is_none()); +} diff --git a/tests/regression/center.rs b/tests/regression/center.rs index f37ff8d34..da691a5c5 100644 --- a/tests/regression/center.rs +++ b/tests/regression/center.rs @@ -26,3 +26,76 @@ fn center_default() { ] )); } + +// Centering on ref 2171 (the first retained nucleosome of the forward +// full-frame record). Rows of the two 2400 bp full-frame records +// (query_length 2400; the primary is 5376) must use the clip offset: +// molecular mode reproduces the primary's rows for the forward record and +// the reverse record's flipped ones; reference mode lists only the +// nucleosomes that lift. Expected values from pysam (#136). +#[test] +fn center_full_frame_records_use_the_clip_offset() { + let bam = fixture("ont_hardclip_full_frame.bam"); + let bed = fixture("ont_hardclip_full_frame.center.bed"); + let rows = |extra: &[&str], query_length: &str| -> Vec<(i64, i64)> { + let mut args = vec![ + "center", + bam.to_str().unwrap(), + "--bed", + bed.to_str().unwrap(), + "--dist", + "200", + ]; + args.extend_from_slice(extra); + let out = run(&args); + let mut lines = out.lines(); + let header: Vec<&str> = lines.next().unwrap().split('\t').collect(); + let col = |n: &str| header.iter().position(|h| *h == n).unwrap(); + let (strand, qlen, ty, st, en, cqs, cqe) = ( + col("strand"), + col("query_length"), + col("centered_position_type"), + col("centered_start"), + col("centered_end"), + col("centered_query_start"), + col("centered_query_end"), + ); + assert!( + lines + .clone() + .any(|l| l.split('\t').nth(strand) == Some("-")), + "minus-strand centering must produce rows" + ); + let mut v: Vec<(i64, i64)> = lines + .map(|l| l.split('\t').collect::>()) + .filter(|f| f[strand] == "+" && f[ty] == "nuc" && f[qlen] == query_length) + .inspect(|f| { + // molecular mode: leading columns are SEQ-relative (anchor at + // SEQ position 37, SEQ length 2400) + if query_length == "2400" && extra.is_empty() { + assert_eq!( + (f[cqs], f[cqe]), + ("-37", "2363"), + "SEQ frame leading columns" + ); + } + }) + .map(|f| (f[st].parse().unwrap(), f[en].parse().unwrap())) + .collect(); + v.sort(); + v + }; + // the primary, for reference (unchanged behaviour) + assert_eq!(rows(&[], "5376"), vec![(-163, -62), (0, 138)]); + assert_eq!(rows(&["--reference"], "5376"), vec![(-164, -62), (0, 137)]); + // forward full-frame record: same rows as the primary; reverse record: (36,116),(163,280) + assert_eq!( + rows(&[], "2400"), + vec![(-163, -62), (0, 138), (36, 116), (163, 280)] + ); + // reference mode: forward keeps only its lifted nucleosome, reverse its two + assert_eq!( + rows(&["--reference"], "2400"), + vec![(0, 137), (36, 116), (162, 278)] + ); +} diff --git a/tests/regression/common.rs b/tests/regression/common.rs index a41b35b2c..8b4cc7581 100644 --- a/tests/regression/common.rs +++ b/tests/regression/common.rs @@ -95,6 +95,24 @@ pub fn run(args: &[&str]) -> String { String::from_utf8(out.stdout).expect("non-UTF8 stdout") } +/// Run ft; return (stdout, stderr). Panics on non-zero exit. +pub fn run_capture(args: &[&str]) -> (String, String) { + let out = Command::new(ft()) + .args(args) + .output() + .expect("failed to spawn ft"); + assert!( + out.status.success(), + "ft exited {}\nstderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + ( + String::from_utf8(out.stdout).expect("non-UTF8 stdout"), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + /// Run `ft add-nucleosomes` on a fixture into a temp BAM, so tests get a /// BAM with the fiberseq_callable tag on disk. pub fn tagged_bam(name: &str) -> tempfile::NamedTempFile { diff --git a/tests/regression/convert_tags.rs b/tests/regression/convert_tags.rs index b2350300a..80d96564b 100644 --- a/tests/regression/convert_tags.rs +++ b/tests/regression/convert_tags.rs @@ -209,9 +209,71 @@ fn assert_stale_records_cleaned(bam: &str, n_supp: usize) { assert_eq!(seen_supp, n_supp, "{bam}"); } +/// Full-read-frame supplementaries (#136): convert-tags keeps nuc/msp under +/// the full read length, marks them NotCallable (no m6A), and strips the +/// consumed legacy tags and MM/ML/MN. +fn assert_full_frame_records_kept(bam: &str, expected: &[(&str, u16, u32)]) { + let out = NamedTempFile::with_suffix(".bam").unwrap(); + convert(&fixture(bam), out.path()); + let mut seen = 0; + for rec in records(out.path()) { + let tag = ma(&rec).expect("every record gets an MA tag"); + for legacy in LEGACY_TAGS { + assert!(rec.aux(legacy).is_err(), "{bam}: legacy tag survived"); + } + if !rec.is_supplementary() { + assert_eq!(tag.split(';').next().unwrap(), rec.seq_len().to_string()); + assert!(rec.aux(b"MM").is_ok(), "{bam}: primary lost MM"); + continue; + } + let qname = String::from_utf8_lossy(rec.qname()).to_string(); + let e = expected + .iter() + .find(|e| qname.starts_with(e.0) && rec.flags() == e.1) + .unwrap_or_else(|| panic!("{bam}: unexpected {qname} {}", rec.flags())); + assert_eq!( + tag.split(';').next().unwrap(), + e.2.to_string(), + "{bam} {qname}: MA frame must be the full read" + ); + assert!( + tag.contains(";nuc") && tag.contains(";msp"), + "{bam} {qname}: nuc/msp lost: {tag}" + ); + assert!( + tag.contains("fiberseq_callable.:1-0"), + "{bam} {qname}: no m6A means NotCallable: {tag}" + ); + for t in [b"MM", b"ML", b"MN"] { + assert!( + rec.aux(t).is_err(), + "{bam} {qname}: {} on a full-read frame", + String::from_utf8_lossy(t) + ); + } + seen += 1; + } + assert_eq!(seen, expected.len(), "{bam}"); +} + +#[test] +fn convert_tags_keeps_full_frame_legacy_records() { + assert_full_frame_records_kept( + "ont_hardclip_supplementary.bam", + &[("8ac3be13", 2048, 29940), ("4bd15181", 2064, 33088)], + ); +} + #[test] -fn convert_tags_cleans_hard_clipped_legacy_records() { - assert_stale_records_cleaned("ont_hardclip_supplementary.bam", 2); +fn convert_tags_keeps_full_frame_ma_records() { + assert_full_frame_records_kept( + "ont_hardclip_full_frame.bam", + &[ + ("f2009f4d", 2048, 5376), + ("f2009f4d", 2064, 5376), + ("7b40cfd0", 2048, 9693), + ], + ); } #[test] diff --git a/tests/regression/extract.rs b/tests/regression/extract.rs index 687eae7d8..859f014d0 100644 --- a/tests/regression/extract.rs +++ b/tests/regression/extract.rs @@ -1,4 +1,4 @@ -use super::common::{fixture, run, select_bed12_cols, select_tsv_cols}; +use super::common::{fixture, run, run_capture, select_bed12_cols, select_tsv_cols}; use tempfile::NamedTempFile; // bed12 columns worth snapshotting: locator + per-record feature data. @@ -134,11 +134,236 @@ fn assert_supplementaries_untagged(bam: &str, n_primary: usize, n_supp: usize) { assert_eq!((seen_primary, seen_supp), (n_primary, n_supp), "{bam}"); } -// Legacy ns/nl/as/al tags, no MM/ML (dorado aligner strips them): caught by -// the hard-clip rule for legacy tags. +/// A hard-clipped supplementary whose tags describe the full-length read +/// (#136): nuc/msp are kept in the full read's frame and lifted through the +/// hard clip, m6A is dropped. Expected values were computed with pysam +/// get_aligned_pairs, independently of ft. +struct FullFrame { + /// qname prefix + qname: &'static str, + flag: u16, + /// SEQ length + /// The annotation frame: on a full-read-frame record the whole read, + /// not SEQ, so it matches the molecular nuc/msp columns and the + /// molecular-mode BED12 end. + fiber_length: i64, + /// every nucleosome of the full read stays in the tag + n_nuc: usize, + /// nuc_starts[0]: BAM orientation, full-read frame + first_nuc_start: i64, + /// ref_nuc_starts entries != -1, in output order (a prefix when shorter + /// than n_lifted) + lifted: &'static [i64], + n_lifted: usize, + same_nucs_as_primary: bool, +} + +fn assert_full_frame_supplementaries(bam: &str, n_primary: usize, expected: &[FullFrame]) { + let out = run(&["extract", "--all", "-", fixture(bam).to_str().unwrap()]); + let mut lines = out.lines(); + let header: Vec<&str> = lines.next().unwrap().split('\t').collect(); + let col = |name: &str| header.iter().position(|h| *h == name).unwrap(); + let (fiber, flag, len, nuc, ref_nuc, m6a) = ( + col("fiber"), + col("sam_flag"), + col("fiber_length"), + col("nuc_starts"), + col("ref_nuc_starts"), + col("m6a"), + ); + let ints = |s: &str| -> Vec { + s.trim_end_matches(',') + .split(',') + .map(|x| x.parse().unwrap()) + .collect() + }; + let mut primary_nucs = std::collections::HashMap::new(); + let mut supp_rows = Vec::new(); + for line in lines { + let f: Vec = line.split('\t').map(str::to_string).collect(); + if f[flag].parse::().unwrap() & 2048 == 0 { + assert_ne!(f[m6a], ".", "{bam} {}: primary lost m6A", f[fiber]); + primary_nucs.insert(f[fiber].clone(), f[nuc].clone()); + } else { + supp_rows.push(f); + } + } + assert_eq!( + (primary_nucs.len(), supp_rows.len()), + (n_primary, expected.len()), + "{bam}" + ); + for f in supp_rows { + let sam_flag: u16 = f[flag].parse().unwrap(); + let e = expected + .iter() + .find(|e| f[fiber].starts_with(e.qname) && e.flag == sam_flag) + .unwrap_or_else(|| { + panic!( + "{bam}: unexpected supplementary {} flag {sam_flag}", + f[fiber] + ) + }); + assert_eq!( + f[len].parse::().unwrap(), + e.fiber_length, + "{bam} {}", + e.qname + ); + assert_eq!( + f[m6a], ".", + "{bam} {} {}: m6A must be dropped on a full-read frame", + e.qname, e.flag + ); + let nucs = ints(&f[nuc]); + assert_eq!( + nucs.len(), + e.n_nuc, + "{bam} {} {}: nuc_starts", + e.qname, + e.flag + ); + assert_eq!( + nucs[0], e.first_nuc_start, + "{bam} {} {}: nuc_starts[0]", + e.qname, e.flag + ); + assert!( + nucs.windows(2).all(|w| w[0] < w[1]), + "{bam}: nuc_starts not ascending" + ); + if e.same_nucs_as_primary { + assert_eq!( + f[nuc], primary_nucs[&f[fiber]], + "{bam} {}: molecular nuc_starts must be unchanged", + e.qname + ); + } + let refs = ints(&f[ref_nuc]); + assert_eq!(refs.len(), e.n_nuc); + let lifted: Vec = refs.into_iter().filter(|r| *r != -1).collect(); + assert_eq!( + lifted.len(), + e.n_lifted, + "{bam} {} {}: lifted nucleosomes", + e.qname, + e.flag + ); + assert_eq!( + &lifted[..e.lifted.len()], + e.lifted, + "{bam} {} {}: ref_nuc_starts", + e.qname, + e.flag + ); + } +} + #[test] -fn extract_drops_legacy_annotations_on_hard_clipped_reads() { - assert_supplementaries_untagged("ont_hardclip_supplementary.bam", 2, 2); +fn extract_lifts_full_frame_ma_records() { + assert_full_frame_supplementaries( + "ont_hardclip_full_frame.bam", + 2, + &[ + FullFrame { + qname: "f2009f4d", + flag: 2048, + fiber_length: 5376, + n_nuc: 31, + first_nuc_start: 64, + n_lifted: 13, + lifted: &[ + 2171, 2392, 2557, 2698, 2862, 3006, 3209, 3438, 3626, 3837, 3978, 4218, 4380, + ], + same_nucs_as_primary: true, + }, + FullFrame { + qname: "f2009f4d", + flag: 2064, + fiber_length: 5376, + n_nuc: 31, + first_nuc_start: 73, + n_lifted: 13, + lifted: &[ + 2207, 2333, 2575, 2721, 2925, 3093, 3231, 3498, 3666, 3806, 3982, 4166, 4359, + ], + same_nucs_as_primary: false, + }, + FullFrame { + qname: "7b40cfd0", + flag: 2048, + fiber_length: 9693, + n_nuc: 44, + first_nuc_start: 84, + n_lifted: 6, + lifted: &[102454, 102616, 102810, 102955, 103409, 103554], + same_nucs_as_primary: true, + }, + ], + ); +} + +// Legacy ns/nl/as/al on hard clips (dorado aligner shape): full-read frame +// with read_length = SEQ + H_lead + H_trail (29940 = 7440+22492+8; +// 33088 = 9910+0+23178). The 8ac3be13 nucleosome that straddles the leading +// clip snaps to the first aligned base (3834036), like across a soft clip. +#[test] +fn extract_lifts_full_frame_legacy_records() { + assert_full_frame_supplementaries( + "ont_hardclip_supplementary.bam", + 2, + &[ + FullFrame { + qname: "8ac3be13", + flag: 2048, + fiber_length: 29940, + n_nuc: 150, + first_nuc_start: 172, + n_lifted: 34, + lifted: &[ + 3834036, 3834112, 3834330, 3834492, 3834646, 3834860, 3834979, 3835452, + 3835701, 3835898, 3836264, 3836417, 3836689, 3836805, + ], + same_nucs_as_primary: false, + }, + FullFrame { + qname: "4bd15181", + flag: 2064, + fiber_length: 33088, + n_nuc: 162, + first_nuc_start: 217, + n_lifted: 48, + lifted: &[ + 10475654, 10475848, 10476012, 10476197, 10476540, 10476719, 10476912, 10477059, + 10477293, 10477492, 10477716, 10477893, 10478079, + ], + same_nucs_as_primary: false, + }, + ], + ); +} + +// One WARN per run that m6A was dropped, with the realignment remedy; no +// stale-frame noise. +#[test] +fn full_frame_m6a_dropped_warns_once() { + let (_, err) = run_capture(&[ + "extract", + "--all", + "-", + fixture("ont_hardclip_full_frame.bam").to_str().unwrap(), + ]); + assert_eq!( + err.matches("their m6A (MM/ML) describes bases this record does not carry and was dropped") + .count(), + 1, + "{err}" + ); + assert!(err.contains("minimap2 -Y -y"), "remedy missing: {err}"); + assert!( + !err.contains("dropping annotations for"), + "full-frame records are not stale: {err}" + ); } // MM/ML/MN copied verbatim onto a 2376H hard-clipped supplementary (the diff --git a/tests/regression/fire.rs b/tests/regression/fire.rs index 38c6b496f..3a451552f 100644 --- a/tests/regression/fire.rs +++ b/tests/regression/fire.rs @@ -1,4 +1,4 @@ -use super::common::{fixture, run, select_tsv_cols, tagged_bam}; +use super::common::{fixture, run, run_capture, select_tsv_cols, tagged_bam}; use rust_htslib::bam::{self, Read}; use tempfile::NamedTempFile; @@ -74,9 +74,130 @@ fn assert_fire_cleans_stale_records(bam: &str, n_scored: usize, n_cleaned: usize assert_eq!((seen_scored, seen_cleaned), (n_scored, n_cleaned), "{bam}"); } +// Records with msp but no m6A cannot be scored: fire writes the model back +// with the full read length, no fire section, the NotCallable marker, and no +// MM/ML/MN (#136). +fn assert_fire_keeps_full_frame_records(bam: &str, n_scored: usize, expected: &[(&str, u16, u32)]) { + let scored = NamedTempFile::with_suffix(".bam").unwrap(); + run(&[ + "fire", + "--ont", + fixture(bam).to_str().unwrap(), + scored.path().to_str().unwrap(), + ]); + let mut reader = bam::Reader::from_path(scored.path()).unwrap(); + let (mut seen_scored, mut seen_full) = (0, 0); + for rec in reader.records() { + let rec = rec.unwrap(); + let ma = match rec.aux(b"Ma") { + Ok(bam::record::Aux::String(s)) => s.to_string(), + _ => panic!("{bam}: record without Ma tag"), + }; + if rec.is_supplementary() { + let qname = String::from_utf8_lossy(rec.qname()).to_string(); + let e = expected + .iter() + .find(|e| qname.starts_with(e.0) && rec.flags() == e.1) + .unwrap_or_else(|| panic!("{bam}: unexpected {qname}")); + assert_eq!( + ma.split(';').next().unwrap(), + e.2.to_string(), + "{bam} {qname}: Ma frame" + ); + assert!( + ma.contains(";nuc") && ma.contains(";msp"), + "{bam} {qname}: nuc/msp lost: {ma}" + ); + assert!( + !ma.contains(";fire"), + "{bam} {qname}: scored without m6A: {ma}" + ); + assert!( + ma.contains("fiberseq_callable.:1-0"), + "{bam} {qname}: not NotCallable: {ma}" + ); + for tag in [b"as", b"ns", b"MM", b"ML", b"MN"] { + assert!( + rec.aux(tag).is_err(), + "{bam} {qname}: {} survived", + String::from_utf8_lossy(tag) + ); + } + seen_full += 1; + } else { + assert!( + ma.contains("msp"), + "{bam}: scorable record missing msp in {ma}" + ); + seen_scored += 1; + } + } + assert_eq!( + (seen_scored, seen_full), + (n_scored, expected.len()), + "{bam}" + ); + // and the scored BAM counts them as NotCallable, never Untagged + let qc = run(&["qc", scored.path().to_str().unwrap()]); + let count = |state: &str| { + qc.lines() + .find(|l| l.starts_with(&format!("fiberseq_callable\t{state}\t"))) + .unwrap() + .split('\t') + .nth(2) + .unwrap() + .parse::() + .unwrap() + }; + assert_eq!( + (count("Callable"), count("NotCallable"), count("Untagged")), + (n_scored, expected.len(), 0), + "{bam}" + ); +} + +#[test] +fn fire_keeps_full_frame_legacy_records() { + assert_fire_keeps_full_frame_records( + "ont_hardclip_supplementary.bam", + 2, + &[("8ac3be13", 2048, 29940), ("4bd15181", 2064, 33088)], + ); +} + #[test] -fn fire_cleans_hard_clipped_legacy_records() { - assert_fire_cleans_stale_records("ont_hardclip_supplementary.bam", 2, 2); +fn fire_keeps_full_frame_ma_records() { + assert_fire_keeps_full_frame_records( + "ont_hardclip_full_frame.bam", + 2, + &[ + ("f2009f4d", 2048, 5376), + ("f2009f4d", 2064, 5376), + ("7b40cfd0", 2048, 9693), + ], + ); +} + +// FireFeats::new slices SEQ by MSP coordinates: full-frame records must be +// skipped before it runs (feats-to-text) and contribute no feature rows. +#[test] +fn fire_feats_to_text_skips_full_frame_records() { + let bam = fixture("ont_hardclip_full_frame.bam"); + let all = run(&["fire", "--ont", "--feats-to-text", bam.to_str().unwrap()]); + let primaries = run(&[ + "fire", + "--ont", + "--feats-to-text", + "-F", + "2048", + bam.to_str().unwrap(), + ]); + assert_eq!( + all, primaries, + "full-frame records must add no feature rows" + ); + // must not panic + run(&["fire", "--ont", "--extract", bam.to_str().unwrap()]); } #[test] @@ -272,3 +393,25 @@ fn fire_bam_mode_coverage_keeps_every_read_and_drop_removes() { "--drop must remove uncallable reads ({n_drop} vs {n_in})" ); } + +// ft fire strips the copied MM/ML from full-frame records, so a second pass +// over its own output has no m6A to drop and must stay quiet. +#[test] +fn full_frame_second_run_is_silent() { + let scored = NamedTempFile::with_suffix(".bam").unwrap(); + let (_, first) = run_capture(&[ + "fire", + "--ont", + fixture("ont_hardclip_full_frame.bam").to_str().unwrap(), + scored.path().to_str().unwrap(), + ]); + assert!( + first.contains("was dropped"), + "first run should warn: {first}" + ); + let (_, second) = run_capture(&["extract", "--all", "-", scored.path().to_str().unwrap()]); + assert!( + !second.contains("was dropped") && !second.contains("hard-clipped"), + "second run warned again: {second}" + ); +} diff --git a/tests/regression/pileup.rs b/tests/regression/pileup.rs index be69919b6..8f29e1f88 100644 --- a/tests/regression/pileup.rs +++ b/tests/regression/pileup.rs @@ -191,3 +191,61 @@ fn pileup_callable_fibers_shrinks_and_intersects() { assert_eq!(callable, fire, "--fire-coverage is the same flag"); assert_eq!(callable, fire_filter, "--fire-filter is the same flag"); } + +/// Weighted column sums of a pileup: (coverage, fire_coverage, nuc_coverage) bp. +fn pileup_sums(args: &[&str]) -> (i64, i64, i64) { + let tmp = NamedTempFile::new().unwrap(); + let mut a = vec!["pileup"]; + a.extend_from_slice(args); + a.extend_from_slice(&["-o", tmp.path().to_str().unwrap()]); + run(&a); + let out = std::fs::read_to_string(tmp.path()).unwrap(); + let mut lines = out.lines(); + let header: Vec<&str> = lines.next().unwrap().split('\t').collect(); + let col = |n: &str| header.iter().position(|h| *h == n).unwrap(); + let (s, e, cov, fire, nuc) = ( + col("start"), + col("end"), + col("coverage"), + col("fire_coverage"), + col("nuc_coverage"), + ); + lines.fold((0, 0, 0), |acc, l| { + let f: Vec = l.split('\t').map(|x| x.parse().unwrap_or(0)).collect(); + let w = f[e] - f[s]; + (acc.0 + f[cov] * w, acc.1 + f[fire] * w, acc.2 + f[nuc] * w) + }) +} + +// Full-read-frame supplementaries add their lifted nucleosomes to the +// nucleosome track (1737 + 1737 + 867 bp on top of the primaries' 8579) but +// never FIRE coverage, and --callable-fibers drops them from the denominator +// entirely (#136). +#[test] +fn pileup_full_frame_nucleosomes_count_fire_does_not() { + let bam = fixture("ont_hardclip_full_frame.bam"); + let bam = bam.to_str().unwrap(); + assert_eq!( + pileup_sums(&[bam, "-F", "2048"]), + (14257, 0, 8579), + "primaries alone" + ); + assert_eq!(pileup_sums(&[bam]), (20337, 0, 8579 + 1737 + 1737 + 867)); + assert_eq!( + pileup_sums(&[bam, "--callable-fibers"]), + (14069, 0, 8579), + "NotCallable reads leave the FIRE denominator" + ); + let scored = NamedTempFile::with_suffix(".bam").unwrap(); + run(&["fire", "--ont", bam, scored.path().to_str().unwrap()]); + index(scored.path()); + let s = scored.path().to_str().unwrap(); + let (_, fire_all, nuc_all) = pileup_sums(&[s]); + let (_, fire_prim, _) = pileup_sums(&[s, "-F", "2048"]); + assert!(fire_prim > 0, "primaries score some FIRE (615 bp today)"); + assert_eq!( + fire_all, fire_prim, + "fire coverage must not change when full-frame records are removed" + ); + assert_eq!(nuc_all, 12920); +} diff --git a/tests/regression/qc.rs b/tests/regression/qc.rs index d4069ed9e..1b7ec28a7 100644 --- a/tests/regression/qc.rs +++ b/tests/regression/qc.rs @@ -327,16 +327,27 @@ fn qc_custom_minimums_reach_state_rows() { assert_eq!(filt, 0, "filtered column agrees with the statet rows"); } -// Records whose tags did not match SEQ count as Untagged in ft qc (#136). +fn callable_counts(bam: &str) -> (i64, i64, i64) { + let out = run(&["qc", fixture(bam).to_str().unwrap()]); + let get = |state: &str| -> i64 { + out.lines() + .find(|l| l.starts_with(&format!("fiberseq_callable\t{state}\t"))) + .unwrap_or_else(|| panic!("{bam}: no {state} row")) + .split('\t') + .nth(2) + .unwrap() + .parse() + .unwrap() + }; + (get("Callable"), get("NotCallable"), get("Untagged")) +} + +// Full-read-frame records have nuc/msp but no m6A: NotCallable, not Untagged +// (#136). MM/ML copied verbatim with no MA and no legacy tags stay stale +// (Untagged). #[test] -fn qc_counts_hard_clipped_records_as_untagged() { - let out = run(&[ - "qc", - fixture("ont_hardclip_supplementary.bam").to_str().unwrap(), - ]); - let row = out - .lines() - .find(|l| l.starts_with("fiberseq_callable\tUntagged\t")) - .expect("Untagged row"); - assert_eq!(row.split('\t').nth(2), Some("2"), "{row}"); +fn qc_counts_full_frame_records_as_not_callable() { + assert_eq!(callable_counts("ont_hardclip_supplementary.bam"), (2, 2, 0)); + assert_eq!(callable_counts("ont_hardclip_full_frame.bam"), (2, 3, 0)); + assert_eq!(callable_counts("ont_hardclip_mmml.bam"), (1, 0, 1)); } From 3a02625ff6dc1cc1cdfa6a1a77083fd1eca629a1 Mon Sep 17 00:00:00 2001 From: "Mitchell R. Vollger" Date: Fri, 18 Sep 2026 13:13:49 -0600 Subject: [PATCH 6/6] fix: do not read the CIGAR of a record that has none hard_clips and query_span called record.cigar() on every record. A bare Record::new() (used by unit tests) has no data, and rust-htslib's cigar() slices that null pointer, which trips a debug assertion; release builds passed, CI's debug build did not. Records with no CIGAR have no clips. --- molecular-annotation/src/liftover.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/molecular-annotation/src/liftover.rs b/molecular-annotation/src/liftover.rs index 3eb161083..d7fdf24fc 100644 --- a/molecular-annotation/src/liftover.rs +++ b/molecular-annotation/src/liftover.rs @@ -486,6 +486,11 @@ impl AlignedBlocks { /// end on both strands: the bases before the first base of SEQ. #[cfg(feature = "htslib")] pub fn hard_clips(record: &rust_htslib::bam::Record) -> (u32, u32) { + // A record with no CIGAR (unmapped, or a bare `Record::new()`) has no + // data to read; `cigar()` on it trips a debug assertion. + if record.cigar_len() == 0 { + return (0, 0); + } let cigar = record.cigar(); ( cigar.leading_hardclips() as u32, @@ -499,7 +504,7 @@ pub fn hard_clips(record: &rust_htslib::bam::Record) -> (u32, u32) { pub fn query_span(record: &rust_htslib::bam::Record) -> u32 { use rust_htslib::bam::record::Cigar; let seq_len = record.seq_len() as u32; - if seq_len > 0 { + if seq_len > 0 || record.cigar_len() == 0 { return seq_len; } record