Skip to content

feat!: keep nuc/MSP/FIRE calls on hard-clipped alignments, drop only their m6A, never panic - #138

Open
mrvollger wants to merge 7 commits into
mainfrom
fix/136-fire-coords-out-of-range-main
Open

mrvollger wants to merge 7 commits into
mainfrom
fix/136-fire-coords-out-of-range-main

Conversation

@mrvollger

@mrvollger mrvollger commented Aug 29, 2026 •

Copy link
Copy Markdown
Member

Fixes #136. Third round of the same class (#31 in 2023, the BamChunk guard narrowing in 2025, #136 now), so this PR is built to end it, not only to stop the panic.

Cause

Aligners that hard-clip supplementary alignments (minimap2 and dorado aligner without -Y) copy the full-length read's tags onto the clipped record. ft fire sliced SEQ with those coordinates and panicked; ft extract printed misplaced positions for forward reads and u32-wrapped ones for reverse reads; ft convert-tags wrote them into an MA tag.

The design

MA-family annotations (nuc, msp, fire, fiberseq_callable) are molecular coordinates of the full read, so on a hard-clipped record they are still correct. Only the liftover has to know that SEQ starts H_lead bases into that frame. MM/ML encode positions as skips over specific bases, so without the clipped bases they cannot be recovered.

Frame rule, decided from the record before anything is parsed (ma_io::record_frame):

  • MA read length equal to SEQ plus both hard clips: full-read frame. Keep the tag as is, lift with the offset, drop MM/ML.
  • MA read length equal to SEQ: clipped frame, tags computed after clipping. Lift as before; the MA tag vouches for the MM/ML next to it. If an MN tag disagrees with SEQ anyway, only the base mods are dropped.
  • Legacy ns/nl/as/al on any hard clip: full-read frame, read length taken as SEQ plus clips (no producer writes them after clipping). Without SEQ, stale.
  • Anything else that matches neither SEQ nor the full read: stale, annotations dropped, record kept, every stale tag stripped on write.
  • SEQ-less records are judged by their CIGAR query span, so a SEQ-less supplementary with a full-read MA tag still gets the offset lift.

Liftover. AlignedBlocks in molecular-annotation carries a query offset; from_record sets it from the rule above; reference lifts subtract it and SEQ-relative projections (ft center) subtract it too. The reverse-strand flip already uses the tag's read length, so both strands come out right with no special case. pyMA mirrors the rule in pysam_utils.from_record and set_aligned_blocks(query_offset=).

No m6A means NotCallable. ft fire cannot score these reads and writes them back unscored; add-nucleosomes and footprint skip them; qc counts them; pileup --callable-fibers excludes them from FIRE denominators. Default pileup counts them in coverage exactly as it counts any Untagged read today.

Writers. In the full-read frame the MA tag is rewritten with the full read length and MM/ML/MN are stripped, so a second run over ft's own output is silent. write_record_with_basemods now writes MN, the SAM spec's frame for MM/ML, so ft predict-m6a, ft ddda-to-m6a and ft strip-basemods output carries the signal other tools use to detect a later hard clip. That adds one tag to those outputs for everyone; it is the one judgement call here.

Telling the user. One warning per run, only when m6A is actually dropped, plus a total at exit:

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; their m6A (MM/ML) describes bases this record does not carry and was dropped, so these reads are not fiberseq-callable. 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.
WARN kept nucleosome/MSP calls on 3 hard-clipped records whose Fiber-seq tags describe the full-length read; they have no m6A and are not fiberseq-callable. Realign with soft clipping: ...

Stale records get a parallel pair of messages. ft qc says the same instead of suggesting ft add-nucleosomes. README and the input BAM --help text state the requirement. The old guard in bio_io.rs that deleted hard-clipped MM/ML records outright is gone.

Breaking

  • Hard-clipped supplementary alignments now keep their nucleosome/MSP/FIRE calls, lifted correctly, and lose only m6A. Before, ft fire panicked or passed stale tags through, ft extract printed wrong coordinates, and records with MM/ML were deleted from output with a per-record warning.
  • extract --all reports fiber_length in the annotation frame (the full read on such records), matching the molecular columns and molecular-mode BED12.
  • MM/ML producers write MN.
  • molecular-annotation: AlignedBlocks::with_query_offset, MolecularAnnotations::query_offset, hard_clips, query_span, full_read_query_offset; pyMA set_aligned_blocks gains query_offset.

Tests

  • molecular-annotation: liftover with an offset, forward and reverse, and from_record in every frame. pyMA: TestFullReadFrame.
  • ma_io.rs unit tests for each branch of the frame rule, the legacy assignment, the callable marker, MN disagreement, SEQ-less hard clips, and the writers.
  • Fixtures: ont_hardclip_supplementary.bam (legacy tags, dorado shape), ont_hardclip_mmml.bam (MM/ML/MN copied, no MA, the plain minimap2 shape: stale), and new ont_hardclip_full_frame.bam (MA-tagged primaries plus forward and reverse hard-clipped supplementaries with expected reference coordinates computed independently with pysam).
  • Regression tests on those for extract (molecular and lifted coordinates, fiber_length), fire (kept calls, stripped MM/ML, silent second run), convert-tags, qc (NotCallable counts), pileup (nucleosome coverage includes them, FIRE coverage does not under --callable-fibers), center, and add-nucleosomes.

Follow-ups (separate PRs)

  • ft qc: a distinct row for full-frame and stale-frame reads rather than folding into NotCallable/Untagged.
  • ft validate: report hard-clipped records and their frames.
  • The fibertools book's ONT quick start should say -Y and why; the bug template should ask for the alignment command.
  • Found while testing: MM without ML panics in the MM/ML parser.
  • Full-frame reads and default pileup coverage: decide whether to skip them there too.

Order

Merge into main after #149 and #141 (both merged). release-plz refreshes the 0.14.0 release PR #152 with this entry marked breaking; molecular-annotation goes to 0.0.4 in the same release.

Reviewed by three adversarial agent panels (46, 45 and 36 agents); the last one's confirmed findings are in the final commit.

@mrvollger mrvollger changed the title fix: skip FIRE scoring when tag coordinates run past the sequence (main) fix: drop annotations that do not fit SEQ in the reader (main) Sep 18, 2026
…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
@mrvollger
mrvollger force-pushed the fix/136-fire-coords-out-of-range-main branch from 353e7a2 to 10d1295 Compare September 18, 2026 14:26
@mrvollger mrvollger changed the title fix: drop annotations that do not fit SEQ in the reader (main) fix: drop annotations that do not fit SEQ instead of panicking in ft fire Sep 18, 2026
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.
@mrvollger mrvollger changed the title fix: drop annotations that do not fit SEQ instead of panicking in ft fire fix!: drop annotations that do not fit SEQ instead of panicking in ft fire Sep 18, 2026
mrvollger added a commit that referenced this pull request Sep 18, 2026
Twin of #148 for main, rebased onto #147 so the two do not conflict on
the release-plz.toml comment. Merging this brings in #147's commit too;
#147 can then be closed as included.

Same content as #148 plus the fix from #150: `semver_check = false`
lives in the `[workspace]` table.

**Do not merge until #132 (the 0.13.1 release PR on `release/v0.13`) is
merged.** release-plz finds its open release PR by branch prefix only. A
push to main with the semver check off would run release-plz-pr, find
#132 by its `release-plz-` branch name, and force-push 0.14.0 content
onto it. Today the only thing preventing that is main's run dying in the
semver check, which this PR removes.

After #132: merge this first on main, then #141 and #138. release-plz
then opens a fresh 0.14.0 release PR.

---------

Co-authored-by: Mitchell R. Vollger <mvollger@gmail.com>
mrvollger added a commit that referenced this pull request Sep 18, 2026
Forward-port to main of the 0.13.1 fix for #140, which shipped from
`release/v0.13` in #142.

## Cause

`--haps` was parsed on `CallPeaksOptions`, but `call_peaks_for_chrom`
hardcoded `haps: false` when it built the pileup. The pileup never made
H1/H2 tracks, so every peak printed the empty-track placeholder (`0 0
-1.0 0 0`) for both haplotypes.

## Fix

`haps` moves from `CallPeaksOptions` into `PeakCallingParams`, which is
what `call_peaks_for_chrom` receives. Both structs are flattened, so the
flag name and help text are unchanged; `--haps` now appears among the
peak-calling options in `--help` instead of last. union-peaks sets it
false because BED intervals carry no HP tag.

The second commit keeps `--haps` from costing more than it must.
Per-haplotype tracks triple the pileup memory per chromosome, and about
a third of that was FIRE element tracking on the H1/H2 tracks that
nothing reads (only `all_data.fire_elements` feeds peak boundaries). The
haplotype tracks are now built without it.

## Check

`NAPA.bam` carries HP tags. `ft call-peaks --haps --min-fire-frac 0.5`
on it:

| | coverage | coverage_H1 | coverage_H2 |
|---|---|---|---|
| before | 95 | 0 | 0 |
| after | 95 | 45 | 14 |

A regression test pins these three values. The call-peaks snapshot and
pileup tests pass.

## Order

Merge into main after #149 and before #138. release-plz opens the 0.14.0
release PR on the first push to main after #149.

Fixes #140

---------

Co-authored-by: Mitchell R. Vollger <mvollger@gmail.com>
mrvollger and others added 2 commits September 18, 2026 09:47
…nals

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.
@mrvollger mrvollger changed the title fix!: drop annotations that do not fit SEQ instead of panicking in ft fire fix!: drop Fiber-seq tags that describe another read than SEQ (hard-clipped alignments) instead of panicking Sep 18, 2026
- 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.
@mrvollger mrvollger changed the title fix!: drop Fiber-seq tags that describe another read than SEQ (hard-clipped alignments) instead of panicking fix!: drop Fiber-seq tags that do not match SEQ (hard-clipped alignments) instead of panicking Sep 18, 2026
…d 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.
@mrvollger mrvollger changed the title fix!: drop Fiber-seq tags that do not match SEQ (hard-clipped alignments) instead of panicking feat!: keep nuc/MSP/FIRE calls on hard-clipped alignments, drop only their m6A, never panic Sep 18, 2026
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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ft fire --ont failing with TEnCATS (enriched) ONT reads

1 participant