fix(quant): make each --writeMappings/--writeBam record describe the alignment it reports - #1141
BenjaminDEMAILLE wants to merge 3 commits into
Conversation
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>
c20f8e1 to
6f553b2
Compare
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>
|
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.
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 Disposition: postponed, not closed — same standard as #1123/#1129. Full realization (2–2.4× on written output, unconditionally, plus a new index artifact in 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 |
…-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
|
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
MAPQ. #1151, standalone, no realization, no 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 The audit did find one real thing, and it is in your category rather than mine: every right-orphan record reports On the opt-in design for after 2.6.0: |
…-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
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>
…-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
|
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 The rest here (CIGAR synthesized from read length, |
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>
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>
6f553b2 to
8e26b9f
Compare
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.
--writeMappingshas 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
developtodayCIGAR=<readLen>M(plus a clip at a transcript end), synthesized from the read lengthCigartype itself holds at most two operations and cannot express an indel.AS= the mapper's chain scoreASagainst a recomputedNMover a whole output found 2.4% of records self-contradicting: a15M1I1D15M1I1D...sawtooth atNM:i:80on a 100 base read, beside anASclaiming about three mismatches. Both cannot be true.MAPQ=1, alwayssamtools 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/Doperations, theNMandMDthat describe it, the score of that same alignment, andMAPQderived 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:
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.26I34MatNM 26where the answer is60MatNM 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_scorefor the bases it claims is refused, and the record falls back to the synthesized CIGAR with noNM/MD, which at least says "no base-level alignment here".Also here, because
NM/MDcannot be honest without it: the index now writesambiguous.bin, the offsets of reference bases indexing had to replace (non-ACGT cannot live in the k-mer structure). Without itNMcounted a match where the input had anNandMDnamed 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/--writeBamnever enterssalmon_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--writeBam, single-mapping--writeBam, multi-mappingThe 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/MDmatchsamtools calmdon every record that carries them, including for the transcript containing anN.ASagrees withNMand the CIGAR on every ungapped record: the score describes the alignment in its own record.SEQholds, pinned by an integration test (crates/salmon-quant/tests/mapping_output.rs) alongside the header, the tag set and the single-end path.samtools quickcheckpasses; SAM output round-trips throughsamtools view -b.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.