Skip to content

feat(quant): --spliced, mapping output in genome coordinates (stacked on #1141, #1142) - #1123

Open
BenjaminDEMAILLE wants to merge 12 commits into
COMBINE-lab:developfrom
BenjaminDEMAILLE:feat/bam-enrichment
Open

BenjaminDEMAILLE wants to merge 12 commits into
COMBINE-lab:developfrom
BenjaminDEMAILLE:feat/bam-enrichment

Conversation

@BenjaminDEMAILLE

@BenjaminDEMAILLE BenjaminDEMAILLE commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Rebased and narrowed, now into a three-PR stack. Following the scope objection below, this PR was split:

  1. fix(quant): make each --writeMappings/--writeBam record describe the alignment it reports #1141 the statements the mapping output makes that are false: a CIGAR synthesized from the read length, AS describing a different alignment from the CIGAR beside it, MAPQ pinned to 1. 2.4% of records were self-contradicting.
  2. feat(quant): fill in the mapping-output fields SAM allows to be absent (QUAL, M5/UR, MC/MQ, ZW, @RG, unaligned reads) #1142 the fields SAM allows to be absent: QUAL, @SQ M5/UR, MC/MQ, ZW, @RG/--rgLine, unmapped records. Omissions, not falsehoods, so a separate decision.
  3. This PR, stacked on both: the genome projection alone. splice.rs, --spliced, XS, the projection accounting. Seven commits, ~1.4k lines.

GitHub cannot point a base branch at a fork, so until #1141 and #1142 merge the diff shown here still includes them.

What --spliced does

A transcript has no introns, so a read spanning an exon junction is contiguous in transcriptome coordinates and its CIGAR never contains an N. --spliced re-expresses each record in genome coordinates: the alignment is cut at the exon boundaries the annotation declares, the gaps reappear as N, @SQ names chromosomes, and records gain XS:A for the transcript's genomic strand.

It needs --annotation <gtf|gff> for the exon structures and --genome <fasta> for the chromosome lengths @SQ must declare (a .fai is used when present). A transcript on the minus strand runs against the genomic axis, so its alignments are turned around whole: CIGAR, sequence, qualities, strand bits, TLEN and MD together. M5 is omitted, because the references are then chromosomes salmon never loaded and a guessed checksum is worse than none.

Placements on a reference the annotation does not describe (a decoy, or a transcript absent from the GTF) are refused rather than half-projected, counted, and reported as a warning at the end of the run. With --sampleUnaligned a fragment whose every placement was refused is written as unaligned instead of vanishing.

Why the genome coordinates are the point

The correctness half of this work stands on its own, but it does not remove a second alignment: a transcriptome-coordinate BAM cannot be fed to the tools that need genome coordinates. Two concrete workflows in our pipeline:

RNA-seq QC. Picard CollectRnaSeqMetrics, RSeQC (read_distribution.py, geneBody_coverage.py, inner_distance.py) and Qualimap rnaseq all take a genome-coordinate BAM plus an annotation. A salmon-quantified sample therefore gets aligned twice today: once by salmon for the quantification we actually want, and once by STAR purely to produce a file for QC. The second alignment is 100% of the QC cost and none of the analysis. With --spliced the QC reads the same alignments the quantification used, which is also the more honest QC: it describes the file the numbers came from rather than a different aligner's view of the same library.

Allele-specific expression. GATK ASEReadCounter and phASER need genome coordinates and real CIGARs with NM/MD, the same second alignment for the same reason.

I am one lab with one pipeline, not a queue of requests, so this is evidence rather than demand. If it is not enough, a decision criterion would help more than a merge: what would count? Two or three independent reports? A user issue naming a tool by name? Happy to keep this parked until then, and #1141 stands on its own either way.

Verification (unchanged from the original review)

Against samtools 1.24, on 500 genes over a 5 Mb simulated chromosome (three to eight exons, both strands) and on human chr22 (hg38) + GENCODE v44, 300k fragments, 1.85M records of which 685k span a junction:

  • NM/MD match samtools calmd recomputed against the genome, on every projected record.
  • samtools quickcheck passes, SAM round-trips through samtools view -b.
  • Reverse-strand transcripts: SEQ/QUAL orientation, strand bits, TLEN sign and MD all agree after the flip.
  • Full workspace test suite green, including the projector unit tests in splice.rs.
@BenjaminDEMAILLE
BenjaminDEMAILLE marked this pull request as draft August 12, 2026 13:32
@BenjaminDEMAILLE
BenjaminDEMAILLE marked this pull request as ready for review August 12, 2026 19:10
@rob-p rob-p added the postponed Deferred, not rejected — revisit in a future release cycle label Aug 21, 2026
@rob-p
rob-p changed the base branch from master to develop August 21, 2026 03:14
@rob-p

rob-p commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Marking this postponed rather than closed, and converting to draft.

The features are genuinely nice — qualities, real CIGARs, NM/MD, read groups, unmapped records, spliced genome-coordinate output would make --writeMappings a "complete" SAM/BAM producer. But that's also the argument against merging it now: it's ~4.5k lines expanding salmon into territory it has never occupied. --writeMappings has always been a diagnostic view of salmon's mappings, not a general-purpose aligner output, and C++ salmon never promised more. Every field this adds is surface we then own — NM/MD correctness against every reference edge case, read-group plumbing, the spliced projection — and it all has to be maintained whether or not anyone uses it.

What would change the calculus is demonstrated need: users showing up with concrete workflows that require salmon's mapping output to be a full SAM/BAM (rather than using a dedicated aligner for that job, which remains the natural tool). If those requests accumulate, this PR is the right starting point and the work here won't be wasted — that's why it's parked (labeled postponed, retargeted at develop, which is the integration branch) instead of closed.

@rob-p
rob-p marked this pull request as draft August 21, 2026 03:14
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title feat(quant): make --writeMappings/--writeBam a complete SAM/BAM (qualities, real CIGARs, NM/MD, read groups, unmapped reads, spliced genome output) Aug 21, 2026
@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor Author

Thanks, and the scope objection is fair, so I acted on it rather than arguing it: this PR is split.

#1141 is the half that is not new territory. --writeMappings has always been a diagnostic view of salmon's mappings, and I agree that is all it should promise, but the view is currently wrong in ways that are not a matter of how much it promises:

  • The CIGAR is built from the read length, so a placement with an indel is written as if it had none and every base past the gap is off by the gap length.
  • Auditing AS against a recomputed NM over a whole output found 2.4% of records self-contradicting: a 15M1I1D15M1I1D... sawtooth at NM:i:80 on a 100 base read, beside an AS claiming three mismatches. Three separate causes, each of which alone produces a false record (anchor search bounded by indel_margin, the search abandoned when the read did not fit at its anchor, and AS describing a different alignment from the CIGAR).
  • An orphan's mapped mate carries MATE_UNMAPPED, but that mate is never written, so the file describes a read it does not contain.
  • MAPQ is hardcoded to 1, so samtools view -q 255 discards the entire file.
  • --sampleUnaligned and --writeQualities are accepted, warn "not yet implemented", and do nothing.

None of that is salmon promising to be an aligner. It is the diagnostic view misdescribing salmon's own mappings, which is worse than a minimal view. #1141 fixes exactly that and adds no new output mode. Its cost is zero when no mapping output is requested (a run that writes no records never enters the realization module), and about 2x on --writeBam runs, measured.

This PR keeps the part that genuinely is new surface: the genome projection, --spliced, XS. It is now stacked on #1141 and I have narrowed it to those seven commits (the diff here still shows #1141 until that merges, since a base branch cannot point at a fork).

On demonstrated need, I have added the concrete workflow to the description rather than claiming a queue of users: Picard CollectRnaSeqMetrics, RSeQC and Qualimap rnaseq all require a genome-coordinate BAM plus an annotation, so a salmon-quantified library is aligned a second time with STAR purely to produce a QC input, and the QC then describes a different aligner's view of the library than the one the numbers came from. ASE calling (ASEReadCounter, phASER) needs the same file for the same reason. That is one lab, not a groundswell, so if it is not enough I would rather have your criterion than a merge: what would count as demonstrated? Parked is fine in the meantime, and #1141 does not depend on the answer.

@BenjaminDEMAILLE BenjaminDEMAILLE changed the title feat(quant): --spliced, mapping output in genome coordinates (stacked on #1141) Aug 21, 2026
@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor Author

Follow-up on the split: it is now three PRs rather than two, because two of the things I had bundled as "correctness" are not the same kind of claim.

The distinction matters for your scope argument: a diagnostic view that omits fields is defensible, and I am not arguing #1142 is obligatory. A diagnostic view that describes an alignment salmon did not make is not, whatever its scope, which is the whole of #1141.

BenjaminDEMAILLE and others added 12 commits August 30, 2026 13:29
Mapping runs ksw2 in KSW_EZ_SCORE_ONLY: it needs the score to rank and filter
placements, not the path that produced it, and skipping the traceback matrix is
the right trade for a kernel that runs once per candidate across the library.
That is also why no CIGAR survives to the record writer, and why the writer has
been synthesizing one from the read length.

This module re-aligns a placement with traceback, on demand, producing the CIGAR
plus the edit distance and MD string that describe it. Nothing calls it yet.

Three things keep it affordable, since it will run once per written record:

* Provably-ungapped fast path. Any alignment containing a gap pays at least
  gap_open + gap_extend and can do no better than match everywhere else, so it
  cannot exceed perfect - (gap_open + gap_extend). The ungapped alignment scores
  perfect - mismatches * (match + mismatch). When the second is at least the
  first, no gapped alignment can win and no DP runs at all. At salmon's defaults
  that admits up to one mismatch, which covers most reads at realistic error
  rates.
* Traceback skipped when the banded optimum equals the ungapped score over the
  full read: filling and walking the traceback matrix is the expensive half.
* Anchor repair by comparison rather than by DP. The mapper's position comes
  from a seed chain, which a mismatch near the 5' end pushes rightwards; sliding
  the read and counting mismatches finds the true start directly, unbounded by
  any band. An exact probe locates candidates first, with the exhaustive sweep
  as the fallback for the fraction of a percent the probe cannot place.

The `ambiguous` slice names reference offsets whose base indexing had to
replace, so NM and MD can report the base the user supplied rather than the
substitute. Callers pass an empty slice until the index records them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Indexing replaces non-ACGT bases with ACGT, as salmon has always done, because
the k-mer structure cannot hold them. Nothing recorded where, so an alignment
over such a position counted a match where the input had an N, and MD named the
substitute instead of the base the user supplied.

The build now writes ambiguous.bin, the offsets it replaced: 40 bytes for the
whole human transcriptome. Alignment still runs against the cleaned sequence,
the only thing it can do, but a record can now report what was actually there.

An index built before this file existed loads as "none recorded" and behaves
exactly as it did, so nothing has to be rebuilt to keep working; rebuilding is
what earns the correction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--writeMappings has always been a diagnostic view of salmon's mappings rather
than a general-purpose aligner output, and that is the right scope. But the view
stated things that were not true of the mappings it described.

* The CIGAR was synthesized from the read length: <readLen>M plus a clip at a
  transcript end. A placement with an indel was written as if it had none, so
  every base past the gap sat at the wrong reference offset, and the record
  contradicted its own placement.
* AS was the mapper's chain score, and nothing else in the record could be
  checked against it. Auditing AS against a recomputed NM over a whole output
  found 2.4% of records self-contradicting: a 15M1I1D15M1I1D... sawtooth at
  NM:i:80 on a 100 base read, beside an AS claiming about three mismatches.
* MAPQ was the constant 1, so `samtools view -q 255`, the standard "uniquely
  placed" filter, discarded the entire file.

Records now carry the CIGAR of an alignment actually realized for that
placement, with real I/D operations, the NM and MD that describe it, the score
of that same alignment, and MAPQ derived from the placement count on STAR's
scale (255 unique, 3, 1, 0), which is what the tools downstream already filter
on.

Three separate causes behind the self-contradicting records, each of which alone
produces a false one:

* The anchor search reached only indel_margin. That bounds indel size and says
  nothing about how wrong a seed-derived position can be: a chain on a repeat or
  a paralogous stretch can imply a start most of a read away. One offending read
  belonged 69 bases earlier, where it matched with three mismatches. Out of
  reach, the banded DP still returned an alignment: a sawtooth of one-base
  indels walking the read diagonally across the reference. The search now covers
  a read length either side.
* The search gave up entirely when the read did not fit inside the reference at
  its anchor, which is exactly the case most worth looking at: an anchor near a
  transcript end becomes a soft-clipped record that throws bases away when the
  read often belongs further in and matches in full. "Does not fit" is now
  treated as "matches nothing" so the search runs.
* ksw2's extension pins the read's first base to the window's first base, so a
  realized alignment can grow rightwards but never shift left, and a mismatch
  near the 5' end left the DP no way to say "the read started earlier" except by
  opening a leading insertion: 26I34M at NM 26 where the answer is 60M at NM 1.
  A leading insertion of n bases is itself the statement that the read began n
  bases earlier, so it is also the correction.

A repaired anchor is a hypothesis, not a verdict: a read with a genuine deletion
also looks badly placed to an ungapped scan, so both anchors are realized and
the better score wins. An alignment scoring below the mapper's own
min_accepted_score for the bases it claims is refused, and the record falls back
to the synthesized CIGAR with no NM or MD, which at least says "no base-level
alignment here".

Cost, measured on 1M fragments at -p 8, best of 3: a run without mapping output
is unchanged (0.89s), because nothing is realized unless records are written;
--writeBam goes from 1.04s to 1.94s on a single-mapping fixture and 1.36s to
3.28s at 2.5 placements per fragment, the cost being per record. NM and MD match
samtools calmd across 2M records, including for the transcript containing an N.

QUAL, M5/UR, MC/MQ, ZW, read groups and unmapped records are all still absent.
Those are omissions the format allows, unlike a CIGAR that does not describe the
alignment beside it, and they are the subject of a separate change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything here is an omission rather than a falsehood: SAM permits `QUAL` to be
`*`, and `M5`, `UR`, `MC`, `MQ` and `ZW` to be missing. The file was still less
useful than it had to be, and salmon already had every value in hand.

* QUAL, taken from the FASTQ and reversed alongside SEQ on the reverse strand,
  so `samtools fastq` can round-trip the library back out of the BAM. It is not
  behind a flag: whenever the input carries qualities the record does. A quality
  string whose length disagrees with the read is dropped rather than written,
  since that mismatch is what makes a record unreadable, and it warns once.
* `@SQ M5`, the MD5 of each reference, and `UR`, the index it came from. M5 is
  what ties a BAM to the exact bases it was made against, and what CRAM needs.
  Where indexing had to replace a base the checksum hashes the `N` that was
  there, so the header names the reference the user supplied. The whole
  transcriptome is hashed in parallel at startup: 0.16s to 0.08s on a 44 MB
  transcriptome, and it matches `samtools dict`.
* `MC` and `MQ`, the mate's CIGAR and mapping quality, which only became
  meaningful once the CIGAR was real and MAPQ was not a constant. A mate-aware
  tool no longer has to sort the file to find them.
* `ZW`, the placement's equivalence-class weight, which salmon already computed
  and nothing else in the file reports.
* `@RG` and `RG:Z` via `--rgLine`, in the spelling bwa and STAR users already
  type, parsed before any mapping work so a malformed line fails immediately
  rather than after the run. Read groups are how a merged BAM keeps track of
  which sample each record came from.
* `@HD VN:1.6 SO:unsorted GO:query` and a `@CO` stating the one ordering
  guarantee. `SO:unknown` was not false, but it hid a property the output does
  have: records for one fragment are contiguous, so a reader can pair mates in a
  single streaming pass instead of sorting first.

`--writeQualities` becomes accepted-and-default rather than warned-as-missing,
since qualities are now written whenever the input has them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A BAM containing only the reads that mapped is a record of one filtering
decision, not of the experiment, and the reads it drops are exactly the ones
anybody debugging a low mapping rate wants. The format has a place for them:
FLAG 0x4, no reference, no position, sequence and qualities intact.

--sampleUnaligned no longer requires --sampleOut (which remains unimplemented).
On its own it adds those records to --writeMappings/--writeBam, and warns when
there is no mapping output for them to go into.

It also covers an orphan's unmapped mate, which is a read that did not align
just as much as one whose whole fragment failed. The mapped mate carries
MATE_UNMAPPED, so without the mate's record the file described a read it did not
contain: samtools fastq could not round-trip it and anything following the flag
chased nothing. The mate is written at the mapped mate's position, so a sort
keeps the pair together, and the two records point at each other. Without
--sampleUnaligned the mapped record keeps RNEXT '*', since naming a mate that
was not written is worse than admitting there is none.

With this, samtools fastq round-trips a whole real library out of the BAM: all
457366 pairs of DRR028171 returned, not one sequence or quality string altered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rdinates

A transcript has no introns, so a read across an exon junction is contiguous in
transcript coordinates and its CIGAR never contains an N. That is an accurate
description of the alignment salmon made, but it is not one a genome browser or
a junction counter can read.

Add salmon_quant::splice, which cuts a transcript-coordinate alignment at the
exon boundaries an annotation declares and fills the gaps with N operations,
producing the spliced genome alignment a genome aligner would have made.

Reverse-strand transcripts run against the genomic axis, so their alignments are
turned around: the CIGAR is reversed, the reported position becomes the
alignment's leftmost genomic base, and the projection reports the flip so the
caller can apply it to SEQ, QUAL and the strand bits together.

Chromosome lengths for @sq come from the genome FASTA index when one exists,
since an annotation never states them and an understated LN makes readers reject
the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wire the genome projector into the record path. With --spliced, @sq names
chromosomes instead of transcripts, positions are genomic, and CIGARs carry N
operations across exon junctions, so the output is the spliced genome BAM the
rest of the RNA-seq toolchain expects rather than a transcriptome one.

A projected reverse-strand transcript turns the alignment around, so both mates'
strand bits swap, their stored bases are reverse-complemented once more, and
TLEN is recomputed over the genomic span, which introns make longer than the
transcript span. NM and MD survive projection unchanged: they describe the read
against the transcript's bases, which are the genome's bases inside an exon.

A placement on a reference the annotation does not describe (a decoy, or a
transcript absent from the GTF) is dropped rather than half-projected, since the
surviving record would claim a mate that is not in the file.

--spliced requires --annotation and --genome, checked before mapping starts
rather than when the first record is written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bramble's GTF reader is commented "1-based inclusive -> 0-based half-open" but
keeps the 1-based start and adds one to the end, so exon lengths are right while
every coordinate sits one base too high.

That shift is invisible in the intron lengths, since both exon ends move
together, and only shows up in the reported position: an end-to-end run put a
read at chr1:272 that belonged at chr1:271. Subtract the one where the exon
blocks are built.

Verified against the genome with samtools calmd: the NM and MD it recomputes
from the projected positions and CIGARs now match the ones salmon wrote, which
they cannot do if the positions are off by a base.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ment

A reverse-strand transcript runs against the genomic axis, so projecting its
alignments turns them around: the CIGAR reverses and the stored sequence is
reverse-complemented. MD was left in transcript orientation, so it described
bases that were no longer the ones under the CIGAR beside it. We wrote
MD:Z:71T28 where the genome says 28A71.

The existing spliced fixture could not show this. It has a reverse-strand
transcript, but its reads carry no mismatches, and an MD of a single run length
is unchanged by reversing it. Only a mismatch makes the orientation visible.

Building a fixture that could show it, 500 genes on a 5 Mb chromosome, three to
eight exons each, both strands, 200k fragments at a 1% error rate, found 105686
of 399524 records disagreeing with samtools calmd recomputed against the genome.
Every one is now identical, including the 161836 that span a junction.

NM needs no such treatment: it counts edits, and reversing an alignment does not
change how many there are.

Also bound the probe search. A probe is useful because it is rare, so one that
matches dozens of places within a few hundred bases is sitting in a tandem
repeat and says nothing about which of them the read came from. Collecting them
all turned a linear scan quadratic on exactly the low-complexity sequence that
produces the most hits, which real transcripts are full of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ations

The projector cut alignments at exon boundaries only within an operation. A
junction landing exactly between two of them was missed, so a deletion ending on
an exon edge left the following match crossing the intron with no N at all: the
bases after the boundary silently claimed reference from the wrong side of it,
and the record read 98M1D2M where the truth is 98M1D...N...2M.

Real data found it. Human chr22 with GENCODE v44, 300k fragments, 1.85M records:
431 of them disagreed with samtools calmd recomputed against the chromosome. No
synthetic fixture could show it, because it needs an indel to land exactly on an
exon edge, which random exon boundaries almost never do.

Six records still differ, in form rather than in bases: a deletion cut in two by
an intron is spelled ^GG where calmd writes the canonical ^G0^G. Same NM, same
reconstructed reference. Documented in the module rather than papered over.

Also confirms the transcript-coordinate path is exact: on the same library
without --spliced, every record carrying NM and MD matches calmd, and the only
records that differ are the ones salmon deliberately writes without them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MD was computed while the alignment was still in transcript coordinates and then
carried across projection, adjusted only for strand. That holds for every
operation except a deletion the projection has to cut in two: two deletions are
spelled with an empty match between them, ^G0^G, not ^GG. The merged form
disagrees with the CIGAR beside it, and samtools calmd writes the canonical one.

Re-derive the string from the projected operations instead. The reference bases
are unchanged by projection, only their grouping is, so expanding the
transcript's MD to one entry per reference base and re-emitting it against the
projected operations gets the grouping right by construction: the separating run
length falls out of the normal rule that one sits between every pair of tokens.
The strand flip then applies to the re-derived string.

This closes the last discrepancy on real data. Human chr22 with GENCODE v44,
300k fragments, 1.85M records of which 685k span a junction: every record
carrying NM and MD now matches samtools calmd recomputed against the chromosome,
where six did not before. The 500-gene synthetic fixture is likewise exact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two pieces of the genome-coordinate output that the correctness split (COMBINE-lab#1141)
deliberately left out, since neither means anything without a projector.

XS:A reports the transcript's genomic strand. StringTie and Cufflinks read an
intron the wrong way round without it, and silently mis-assemble.

A projection can refuse every placement of a fragment when the annotation does
not describe its transcript, and the fragment then produced no records at all:
on a fixture where half the transcripts were absent from the GTF, half the
fragments disappeared with no warning and no accounting. Such a fragment is now
written as unaligned when --sampleUnaligned is on, and the projector counts what
it refuses so the run ends with a warning naming the number instead of a
silently short file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BenjaminDEMAILLE
BenjaminDEMAILLE marked this pull request as ready for review September 26, 2026 11:16

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

postponed Deferred, not rejected — revisit in a future release cycle

2 participants