Skip to content

fix(quant): make each --writeMappings/--writeBam record describe the alignment it reports - #1141

Open
BenjaminDEMAILLE wants to merge 3 commits into
COMBINE-lab:developfrom
BenjaminDEMAILLE:feat/mapping-output-correctness
Open

BenjaminDEMAILLE wants to merge 3 commits into
COMBINE-lab:developfrom
BenjaminDEMAILLE:feat/mapping-output-correctness

Conversation

@BenjaminDEMAILLE

@BenjaminDEMAILLE BenjaminDEMAILLE commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Split out of #1123 (the third slice of it). This PR carries only the part where the current output states something false about salmon's own mappings. The additive fields SAM allows to be absent are in the PR stacked on top of this one, and the genome projection stays in #1123.

--writeMappings has always been a diagnostic view of salmon's mappings rather than a general-purpose aligner output, and that is the right scope. No new output mode is added here. The view just stops misdescribing what it shows.

What is false on develop today

Statement the file makes Why it is false
CIGAR = <readLen>M (plus a clip at a transcript end), synthesized from the read length A placement with an indel is written as if it had none, so every base past the gap sits at the wrong reference offset. The Cigar type itself holds at most two operations and cannot express an indel.
AS = the mapper's chain score Nothing 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. Both cannot be true.
MAPQ = 1, always samtools view -q 255, the standard "uniquely placed" filter, discards the entire file. A constant that says "ambiguous" for a uniquely placed fragment is not an estimate, it is a wrong one.

What this PR changes

Records 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, the sawtooth above. The search now covers a read length either side.
  • The search gave up 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.
  • 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 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/MD, which at least says "no base-level alignment here".

Also here, because NM/MD cannot be honest without it: the index now writes ambiguous.bin, the offsets of reference bases indexing had to replace (non-ACGT cannot live in the k-mer structure). Without it NM counted a match where the input had an N and MD named the substitute. 40 bytes for the whole human transcriptome, and an index built before this file existed loads as "none recorded" and behaves exactly as it did.

Cost

Realization runs only for placements that are written, and only when mapping output is on: a run without --writeMappings/--writeBam never enters salmon_map::realize. Two things keep the written path affordable: an affine-scoring bound that proves the ungapped alignment optimal (no DP at all for up to one mismatch at salmon's defaults), and skipping the traceback when the banded optimum equals the ungapped score over the full read.

1M fragments, -p 8, order-alternated, best of 3, on 20k unique transcripts and on 5k genes x 4 isoforms (2.5 placements per fragment):

master this branch
no mapping output, single-mapping 0.89s 0.89s
no mapping output, multi-mapping 0.94s 0.92s
--writeBam, single-mapping 1.04s 1.94s
--writeBam, multi-mapping 1.36s 3.28s

The default path is unchanged. The cost is per written record, so it grows with multi-mapping rather than shrinking.

Verification

Against samtools 1.24, on the two simulated fixtures, human chr22 (hg38) + GENCODE v44, and real reads (DRR028171, 457366 pairs of 151 bases):

  • NM/MD match samtools calmd on every record that carries them, including for the transcript containing an N.
  • AS agrees with NM and the CIGAR on every ungapped record: the score describes the alignment in its own record.
  • Every record's CIGAR accounts for exactly as many read bases as SEQ holds, pinned by an integration test (crates/salmon-quant/tests/mapping_output.rs) alongside the header, the tag set and the single-end path.
  • samtools quickcheck passes; SAM output round-trips through samtools view -b.
  • Full workspace suite green.

Not in this PR

QUAL (still *), @SQ M5/UR, MC/MQ, ZW, @RG/--rgLine, and unmapped records. Those are omissions the format allows, which is a different argument from a CIGAR that does not describe the alignment beside it, so they are in the stacked PR. Genome-coordinate output (--spliced) stays in #1123.

BenjaminDEMAILLE added a commit to BenjaminDEMAILLE/salmon that referenced this pull request Aug 21, 2026
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 force-pushed the feat/mapping-output-correctness branch from c20f8e1 to 6f553b2 Compare August 21, 2026 20:52
BenjaminDEMAILLE added a commit to BenjaminDEMAILLE/salmon that referenced this pull request Aug 21, 2026
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 changed the title fix(quant): make --writeMappings/--writeBam records describe the alignment they claim Aug 21, 2026
@rob-p

rob-p commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

This is a careful piece of work, and splitting it out of #1123 into "false statements" vs "permitted omissions" was the right decomposition discipline. But I want to push back on the premise before the implementation, because I think the categories come out differently under the contract this output has always had.

--writeMappings is a pseudobam. It has been, deliberately, since the C++ days: a diagnostic view of salmon's mappings — positions, pairing, and the scores quantification actually used — with known fields spoofed or unwritten, for brevity and for computational convenience. The main path does not compute base-level alignments (score-only DP, no traceback), and that is a feature: the compute follows what quantification needs, not what an output format could in principle carry. Under that contract, re-examining your table:

  • The <readLen>M CIGAR is a tradeoff, not a falsehood — a nominal placeholder in a view that never promised base-level alignments. Where I'll concede the point entirely: we never wrote the contract down. The output-formats page says nothing about nominal CIGARs, constant MAPQ, or what AS means. That's our defect, not the format's — a "pseudoalignment output contract" paragraph is landing with the 2.6.0 docs pass, and with it stated, a reader who treats the CIGAR as real has been warned rather than misled.
  • AS is not wrong — it's the most correct value this file can carry. It is the score quantification used for that placement. Recomputing it against a freshly realized alignment would make the output disagree with the quant run it documents, which for a diagnostic view is strictly worse. Your 2.4% "self-contradiction" is a contradiction against the nominal CIGAR — i.e., against the field the contract says is nominal.
  • MAPQ=1 is the one item where you've found something real on my criterion (a value that misleads at zero computational justification): deriving MAPQ from the placement count costs nothing — the count is already in hand — and fixes the genuine samtools view -q footgun. I'd take that as a tiny standalone PR (MAPQ scale + docs), fully decoupled from realization.

The most valuable thing in this PR may be a question it raises but doesn't answer. Your three anchor pathologies (seed-derived positions off by most of a read, edge give-ups, ksw2's left-pinning) were found in the realization DP — but the same seeding and chaining feed the scoring path that quantification uses. If those pathologies distort scores on the same reads, that's a quantification-accuracy bug and jumps every queue, BAM or no BAM. If they don't (e.g., such placements score below min_accepted_score and drop out), then the 2.4% is an artifact of auditing a nominal field. Please open an issue with your fixtures and the sawtooth reads — answering that is worth more than any output field.

Disposition: postponed, not closed — same standard as #1123/#1129. Full realization (2–2.4× on written output, unconditionally, plus a new index artifact in ambiguous.bin) is a real capability some users may genuinely want — but it must be opt-in: something like --writeMappings=nominal|realized, where nominal stays the cheap default contract and realized buys NM/MD/real-CIGARs knowingly. That's a design discussion for after 2.6.0 lands — we are mid-flip and not taking on a second behavioral surface — and #1142's additive fields belong to the same discussion. The engineering here (the affine bound proving ungapped optimality, the both-anchors-realized hypothesis testing, the refuse-below-min_accepted_score fallback) is exactly what that opt-in mode would be built from, so nothing is wasted by parking it.

Concretely: (1) docs contract lands with 2.6.0; (2) MAPQ fix welcome now as its own small PR; (3) the scoring-path question becomes an issue; (4) this PR and #1142 get the postponed label pending the opt-in design.

@rob-p rob-p added the postponed Deferred, not rejected — revisit in a future release cycle label Aug 22, 2026
@rob-p
rob-p marked this pull request as draft August 22, 2026 20:03
rob-p added a commit that referenced this pull request Aug 22, 2026
…-writeBam

The nominal-CIGAR / mapper-score-AS / nominal-MAPQ design has been the
contract since the C++ days, but the docs never said so — which is the
one place #1141's 'the file states something false' framing had real
purchase. Stated explicitly now: the output is a diagnostic view of
salmon's mappings, fields are nominal by design, and an opt-in realized
mode is a design discussion (#1141), not an implicit promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016cfXr9P5JDqoEtafmTGpNs
@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor Author

Taking the disposition as written, and conceding the framing on two of the three items.

The CIGAR. You are right that a nominal placeholder in a view that never promised base-level alignments is a tradeoff rather than a falsehood, and that the missing piece is the written contract. I will say where I still differ, once, and then drop it: my objection was never that the CIGAR is nominal, it is that <readLen>M is indistinguishable from a real one. A reader cannot tell it apart without knowing the contract, which until 2.6.0 was unwritten. If the docs pass says so plainly, the field stops being a trap, and that resolves it. * would resolve it too and cost nothing, but it would break every reader that tolerates a nominal CIGAR today, so it is not obviously better.

AS. Conceded, and I had it backwards. It is the score quantification used, and recomputing it against a realized alignment would make the file disagree with the run it documents. For a diagnostic view that is worse, not better. My 2.4% was a contradiction against the nominal field, exactly as you say.

MAPQ. #1151, standalone, no realization, no ambiguous.bin: the scale, its docs, and a unit test. On the fixture, -q 255 goes from selecting 0 records to selecting 433027.

The scoring-path question: #1152, and the answer is no. I built a fixture against the three pathologies (half the transcripts paralogues, indels in 15% of reads, mismatches concentrated in the first 30 bases) and compared the mapper's per-mate AS against a realized alignment at the repaired anchor, joined per record so the units match, then compared the per-fragment weight vectors the EM would see. 99.3% of records agree; 0.24% of multi-placement fragments would rank their placements differently; nothing survives the exp(-(best - score)) decay into a materially different apportionment. The pathologies live in the realization DP, as you suspected.

The audit did find one real thing, and it is in your category rather than mine: every right-orphan record reports AS:i:0. The mapper stores an orphan with score and r1_score both set to the anchor's score, and the writer reports read2's as score - r1_score. Quantification is unaffected (it weights by score), but 0.47% of records in the file claim a zero-scoring alignment that actually scores 182 to 200. Zero computational justification, one line. I have not touched it, since you scoped #1151 to MAPQ; say the word and it joins that PR or becomes its own.

On the opt-in design for after 2.6.0: --writeMappings=nominal|realized is the right shape, and I would rather have the discussion then than argue it now. One thing worth carrying into it, from #1140's measurement: under --deterministic the mapping output is byte-different on every run while the record set is identical, so whatever realized ends up promising, it should be explicit that it does not promise a reproducible file.

rob-p added a commit that referenced this pull request Aug 22, 2026
…-writeBam

The nominal-CIGAR / mapper-score-AS / nominal-MAPQ design has been the
contract since the C++ days, but the docs never said so — which is the
one place #1141's 'the file states something false' framing had real
purchase. Stated explicitly now: the output is a diagnostic view of
salmon's mappings, fields are nominal by design, and an opt-in realized
mode is a design discussion (#1141), not an implicit promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016cfXr9P5JDqoEtafmTGpNs
rob-p pushed a commit that referenced this pull request Aug 22, 2026
Every record in --writeMappings/--writeBam carried MAPQ 1, which reads as "this
placement is probably wrong" about placements salmon kept. The practical cost is
that `samtools view -q 255`, the standard "uniquely placed" filter and the one
STAR output trains people to use, discards the entire file.

salmon has no phred-scaled probability to report: its design is to keep every
plausible placement and let the EM apportion them. What it does have, already in
hand when the records are written, is the number of placements, which is what
STAR reports. So MAPQ now follows STAR's mapping: 255 for a uniquely placed
fragment, 3 for two placements, 1 for three or four, 0 beyond.

No alignment work is implied. This is the one placeholder in the output that
costs nothing to replace, which is why it is separated from the realization work
in #1141: on a 300k-fragment fixture the distribution is 433027 records at 255,
199088 at 3, 168502 at 1, 89481 at 0, and `-q 255` now selects the uniquely
placed records instead of nothing.

The output-formats page documents the scale, since a reader has to know that a
salmon MAPQ counts placements rather than estimating a probability.

Refs #1140, #1141.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rob-p added a commit that referenced this pull request Aug 22, 2026
…-writeBam

The nominal-CIGAR / mapper-score-AS / nominal-MAPQ design has been the
contract since the C++ days, but the docs never said so — which is the
one place #1141's 'the file states something false' framing had real
purchase. Stated explicitly now: the output is a diagnostic view of
salmon's mappings, fields are nominal by design, and an opt-in realized
mode is a design discussion (#1141), not an implicit promise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016cfXr9P5JDqoEtafmTGpNs
@rob-p

rob-p commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Scoping decision recorded (2026-08-24): the remainder of this PR is deferred to post-2.6.0.

One slice was carved out and taken into 2.6.0 as #1180 — the right-orphan AS:i:0 bug you flagged as being in our category. Confirmed in full (717,526 right-orphan records at AS:i:0, both SAM and BAM, quant.sf byte-identical before/after) and fixed as its own surgical PR. Thanks for the precise diagnosis — the score - r1_score collapse was exactly it.

The rest here (CIGAR synthesized from read length, AS-vs-NM self-consistency) needs the design work it always did and is deferred to post-2.6.0, tracked in #1179. The MAPQ slice already landed via #1151 and #1171. Not closing this PR — it remains the home for the deferred work.

BenjaminDEMAILLE and others added 3 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>
BenjaminDEMAILLE added a commit to BenjaminDEMAILLE/salmon that referenced this pull request Aug 30, 2026
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 force-pushed the feat/mapping-output-correctness branch from 6f553b2 to 8e26b9f Compare August 30, 2026 11:44
@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