aligner core: annotated-junction stitching, alignEndsType, in-recursion length penalty - #146
aligner core: annotated-junction stitching, alignEndsType, in-recursion length penalty#146BenjaminDEMAILLE wants to merge 17 commits into
Conversation
a35f265 to
3661533
Compare
…ind() STAR's stitcher resolves an annotated junction by index (`sjA` / `sjdbInd`) into `mapGen.sjdb*`, then reads that entry's motif, shiftLeft and shiftRight. rustar-aligner had only a boolean `is_annotated()` over a HashMap, so neither the `sjAB` fast path nor annotated-boundary snapping could be expressed. Add the missing half: - `SpliceJunctionDb::table: Vec<PreparedJunction>`, the annotated junctions in the exact order they occupy the Gsj buffer. - `find(stored_start, stored_end) -> Option<usize>`, a port of STAR's `binarySearch2`: binary search on the donor, then a backward/forward scan of the equal-donor run for the acceptor. Coordinates are genome-absolute, so no chromosome index is needed. - `entry(i)`, `table_len()`, `from_prepared()`, `set_table()`. No index format change. `PreparedJunction` already carries motif, shift_left, shift_right and strand; `sjdbInfo.txt` already writes and reads all four; and `sort_and_dedup` already establishes the `(stored_start, stored_end)` ordering the search needs. Existing indices work unchanged. The three population paths now agree: - `genomeGenerate` builds the db from the same `prepared` array it feeds to `build_gsj`, so table order and Gsj order match by construction. - The index-load path uses `from_prepared(prepared_junctions)`. - The align-time `--sjdbGTFfile` path derives motif/shift via `prepare_junction` + `sort_and_dedup`, which it previously discarded. Whenever the index carries junctions, its array wins as the table even if a GTF is also supplied at align time: `sj_a` tags come from `decode_gsj_hit`, which indexes that array, so anything else would make a tag address the wrong junction. Behaviour-neutral for canonical junctions, where stored coordinates equal the raw GTF ones. For non-canonical junctions the `genomeGenerate` in-memory db is now keyed on stored coordinates like the other two paths, which is what the stitch-time scan produces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ted-junction shortcut STAR tags every seed derived from the sjdb genome insert with the index of the junction it came from (`sjA`), then, when the previous exon and the incoming piece carry the *same* tag and are read-adjacent, stitches them straight onto the annotated junction instead of searching for a boundary. rustar-aligner computed that index and threw it away. Plumbing: - `decode_gsj_hit` returns the `junction_idx` it already computed. Both halves of a boundary-crossing hit carry it: they are two flanks of one junction. - `expand_hit`, `WinCandidate`, `WindowAlignment` and `ExonBlock` carry `sj_a`, `-1` for ordinary real-genome hits. - The overlap dedup in `cluster_seeds` now compares `sj_a` alongside `mate_id`, matching STAR's `e.sjA == sjA` guard in `assignAlignToWindow`. Seeds derived from different annotated junctions are no longer collapsed just because they share a diagonal. The shortcut itself, at the head of `stitch_align_to_transcript`: - fires when both sides carry the same non-`-1` tag, sit on the same mate, are exactly read-adjacent, and are separated on the genome; - takes exon B verbatim at the annotated boundary, adopts the stored motif and shifts, marks the junction annotated, and adds `sjdbScore`; - rejects (STAR's -1000006, `None` here) when micro-repeats around a non-canonical annotated junction are larger than the pieces being stitched. Without it, the general boundary search flushes a repeat-adjacent junction to its leftmost position and lands a few bases off the annotated coordinates (STAR's `11M1545N38M` becoming `9M1545N40M`), which also forfeits the annotated bonus because the shifted coordinates no longer match the database. Receiver coordinates are half-open where STAR's are inclusive, so `rBstart == rAend + 1` is `wa.read_pos == last_exon.read_end` and `gAend + 1 < gBstart` is `last_exon.genome_end < wa.sa_pos`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ted coordinates Closes item #4b of STAR-RS-COMPARISON.md §7.2, which §7.4 calls "the best remaining lever". The splice branch previously asked the annotation a yes/no question and, on a hit, swapped the motif penalty for `sjdbScore`. It never used what the annotation actually says about *where* the junction is. STAR does three things with the entry it finds: 1. adopts the stored motif (`jcan = sjdbMotif[ind]`), so the mismatch gate, `outFilterIntronMotifs` and SJ.out.tab all see the annotated motif rather than one re-derived from the genome; 2. for a non-canonical entry, shifts `jR` by `shiftLeft`, moving the boundary off the leftmost-flush position the scan picked and onto the annotated one; 3. rejects (-1000006) when the micro-repeat is larger than either piece, or when the shift would push the donor past the end of piece B. All three are now done. Because `jr_shift` is consumed afterwards to move exon A's right edge and exon B's left edge together, shifting it relocates the whole junction coherently and leaves the intron length untouched. The mismatch gate moves after the lookup, matching STAR's ordering: it must test the annotated motif, not the scanned one. This changes behaviour on annotated non-canonical junctions, which is the point. `find()` and `is_annotated()` are queried separately rather than one wrapping the other: junctions inserted by two-pass mode live only in the map and carry no motif or shift, so they must still count as annotated while contributing nothing to snap. New tests: `noncanonical_annotated_junction_snaps_to_annotated_coords` measures where the unannotated search lands, annotates exactly that junction, and asserts both boundaries move by `shiftLeft` while the intron keeps its length (and that a canonical entry at the same coordinates is left alone). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ead-length mismatch cap Adds the flags this theme needs that scverse#145 did not bring: `alignEndsProtrude`, `alignSoftClipAtReferenceEnds`, `alignInsertionFlush`, `alignTranscriptsPerReadNmax`, `outFilterMismatchNoverReadLmax`, `seedNoneLociPerWindow`, `seedSplitMin`. Off-menu values are rejected loudly rather than silently ignored. `--alignEndsType` and its `ext[mate][end]` matrix come from scverse#145; this branch builds on that rather than duplicating it. The scorer gains only the three fields scverse#145 does not have: `flush_right`, `soft_clip_at_reference_ends` and `p_mm_max_read`. Two behaviours land here: - `--alignInsertionFlush Right` now works. The insertion-placement scan accepts ties as well as strict improvements, then walks the insertion further right for as long as the read keeps matching, rejecting when it runs out of read on the B side (STAR -1000009). `None`, the default, is bit-identical to before. - `--outFilterMismatchNoverReadLmax` is enforced. STAR's `outFilterMismatchNmaxTotal` is the tightest of three caps: absolute, a fraction of the mapped length, and a fraction of the read length. Only the first two were applied, so the flag had nothing to bind on. Now in `AlignmentScorer::mismatch_nmax_total`. Note for a follow-up: the pre-existing `--clipAdapterType` validation sits inside the `solo_enabled()` branch of `try_parse_from`, so it only fires for STARsolo runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An alignment may no longer be soft-clipped past the end of its chromosome when `--alignSoftClipAtReferenceEnds No` is given. `Yes`, the default, leaves behaviour unchanged. Sits directly after the end-to-end boundary check from scverse#145: both are "this extension may not run off the reference" rules and share the extension results. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ot after it Closes item scverse#7 of STAR-RS-COMPARISON.md §7.2. STAR applies `scoreGenomicLengthLog2scale` in the base case of `stitchWindowAligns`, before the dedup and eviction that decide which transcripts survive the window. rustar-aligner applied it only in `finalize_transcript`, which runs after those decisions, so the recursion ranked candidates on unpenalised scores: a sprawling alignment could displace a compact one of equal raw score, and the penalty arrived too late to matter. `WorkingTranscript` gains `rank_score` = `score` + genomic-length penalty, computed in the base case and used by the three places that compare siblings: the two `same_structure` dominance tests and the `min_by_key` eviction. `rank_score` is kept separate from `score` rather than folded into it. The PE mate-split path derives two per-mate transcripts from one combined score, and splitting an already-penalised score across mates has no clean semantics. `finalize_transcript` still applies the penalty to the final score exactly as before, so nothing is double-counted. This changes which alignment wins among equal-raw-score candidates, which is the point. §7.5's lesson from the dropped item scverse#2 applies: if the differential measures negative, this commit is the one to revert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tsPerReadNmax
Adds `test/simulate_junction_reads.py` and a `yeast_junction_gtf` tier.
The existing tiers align real RNA-seq reads, of which only a small fraction
cross an intron — yeast has a few hundred — so the annotated-junction code
paths are barely exercised and a divergence in them is easy to miss. The
simulator draws reads from spliced transcript sequences centred on annotated
junctions, half of them reverse-complemented, from a fixed seed so the FASTQ is
reproducible. Every read crosses an annotated junction.
Also wires `--alignTranscriptsPerReadNmax`: STAR breaks out of the window loop
once one more window's worth of transcripts could overrun the per-read cap, so
the cap is never exceeded rather than merely noticed afterwards.
Measured with this tier, 1000 reads, yeast R64-1-1 with the Ensembl 110 GTF,
against native STAR 2.7.11b, records compared on FLAG/RNAME/POS/CIGAR:
origin/main 835 / 1000 identical
this branch 933 / 1000 identical
gained 98, regressed 0
junctions (total / annotated)
STAR 155 / 155
origin/main 149 / 131
this branch 149 / 131
The gain is entirely in CIGARs: the junction set is unchanged, but 98 reads now
place their exon boundaries where STAR places them. Against an unannotated
index both binaries score 938/1000, gained 0, lost 0, confirming the
in-recursion length penalty is neutral there and the whole improvement comes
from annotated-boundary snapping.
Output is byte-identical at `--runThreadN` 1, 4 and 16.
Adds `docs-old/dev/divergences.md`, recording the two places this branch
knowingly differs from STAR: the extend-to-end rejection signal, and the
missing per-junction strand on non-canonical annotated junctions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scverse#145 handles the same case with an explicit EXTEND_TO_END_KILL sentinel checked before the score clamp, so there is no divergence left to record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3661533 to
8a0a47a
Compare
…is never a deletion Two defects, found by measuring the annotated-junction tier against STAR rather than by reading the code. Together they took that tier from 96.957% to 99.790% exact agreement, and they compound: the first hides the second. **`genomeGenerate` dropped one-base introns.** The GTF junction extractor required `intron_end > intron_start`, which rejects an intron of length one. STAR skips a pair only when the exons touch or overlap (`GTF_transcriptGeneSJ.cpp:123`: `exonLoci[iex][exS] <= exonLoci[iex-1][exE]+1`), so `exon2.start == exon1.end + 2` is a junction it keeps. Yeast R64-1-1 has three. The index carried 361 junctions against STAR's 364, and the three missing were exactly those. `sjdbList.out.tab` is now identical to STAR's. **An annotated gap shorter than `--alignIntronMin` was scored as a deletion.** STAR resolves `sjdbInd` *before* testing `Del >= alignIntronMin` (`stitchAlignToTranscript.cpp:198-241`): on a hit it takes the annotated branch whatever the gap length, and the length test only decides for gaps the annotation does not know. Here the annotation lookup sat inside the already-decided splice branch, so a short annotated gap could never reach it. The lookup moves out; `is_splice` becomes `annotated || long enough`. The CIGAR builder re-derived the same decision from the gap length alone, so even once the score was right the operation was still `D`. It now asks the same question through a shared `gap_is_junction`, which consults the annotation for gaps below the threshold. Reads crossing yeast's one-base introns come out `74M1N76M` as STAR writes them, not `74M1D76M`. Measured on the 1000-read annotated-junction tier, against STAR 2.7.11b: CIGAR disagreements 29 -> 2 AS disagreements 29 -> 2 exact (tie-adjusted) 924/953 (96.957%) -> 951/953 (99.790%) SE and PE on the 10k yeast tiers are unchanged at 99.898% and 99.849%: those reads do not cross a one-base intron, which is why this stayed invisible until the tier existed.
STAR compares `maxScore`, then `gLength` — the alignment's genomic span — and leaves anything still tied to whichever window it reached first (`ReadAlign_stitchPieces.cpp:340`). The second key here was the junction count, which is a proxy for the span rather than the span itself: usually correlated, not always equal. Using the span directly gains 6 reads on the 10k SE tier (8791 -> 8797 exactly matching STAR) and costs none anywhere. PE and the junction tier are unchanged. What this does not fix is the residual: among the 37 cross-chromosome primary differences on the junction tier, all 37 are multimappers with identical scores, and 18 have identical CIGARs — so identical spans too. STAR's remaining discriminator is window iteration order, which this codebase deliberately does not reproduce, since windows are built per read in parallel to keep output invariant to `--runThreadN` (DIVERGENCE.md §1.1). Those are ties in both tools; only the choice among equals differs.
|
Followed the residual to its end rather than leaving it at "documented divergence". Reporting the attempt because it failed, and the numbers say something useful. What the remaining differences are37 reads on the junction tier choose a different primary locus. All 37 are multimappers with identical alignment scores; 18 also have identical CIGARs, so identical genomic spans. Both tools find the same alignment set and disagree only about which equal member is marked primary. STAR's tie-break is Why I thought that was closed, and why it is notI had written that reproducing window order would cost thread-invariance. That was wrong: STAR's window order is deterministic per read — anchor pieces in storage order, positions within a piece in suffix-array order — and this codebase builds windows the same way, per read, in parallel. Reproducing it costs nothing in parallelism. So I tried it: drop the positional tie-break keys and let Rust's stable sort preserve discovery order, which is what The measurement
+21 on the tier, −142 on SE. Net worse, so it is reverted. What that tells us: this codebase's anchor iteration order is not STAR's What reaching exact parity would takeReproducing STAR's Until then the residual is what it looks like: ties, resolved differently, on reads where both tools found the same alignments. Thread invariance is intact — 10k SE reads at |
Two divergences in the MMP chain search, both read from ReadAlign_mapOneRead.cpp: 1. flagDirMap (:50, :61, :74). When the very first left-to-right search of a good piece maps it all the way to the piece end, STAR skips the istart == 0 chain of the right-to-left direction; chains from istart > 0 still run. rustar-aligner always ran both. The condition STAR writes is `Shift + L == splitR[1][ip]` with Shift still equal to the piece start, so the piece's offset in the concatenated read enters the sum: find_seeds_at takes that offset, and find_seeds keeps the old signature with an offset of 0. 2. The chain-loop bound. STAR continues while `istart*Lstart + Lmapped + P.seedMapMin < splitR[1][ip]`, i.e. while strictly more than seedMapMin bases remain. rustar-aligner continued while at least seedMapMin remained, so it ran one extra search on a piece with exactly seedMapMin bases left. Neither changes an output byte on the yeast tiers: a full-length match found by the reverse direction is dropped by the storeAligns duplicate rule anyway, and a 5-base MMP in a 12 Mb genome exceeds seedMultimapNmax and is never stored. They are here because the search should be STAR's search, not because they moved a number. The toy fixtures in the seed tests are 3-8 base reads, shorter than the default seedMapMin, so under the corrected bound STAR would not search them at all. They now pass --seedMapMin 1 through a toy_params helper so the assertions still exercise the MMP mechanics they were written for.
STAR's assignAlignToWindow guards its duplicate test with both `aFrag == WA[iA][WA_iFrag]` and `sjA == WA[iA][WA_sjA]`. Two aligns on the same diagonal that were split out of different annotated junctions are not duplicates of each other. The pre-stitch diagonal dedup keyed on (diagonal, mate_id) only. A read whose last exon is a few bases long produces two entries covering the same donor-side bases, one tagged with the junction it arrives on and one with the junction it leaves by; the dedup collapsed them to the longest, which kept only the arriving tag. The micro-exon entry was then left without a partner carrying the same sjA, the annotated-junction path could not fire, and the terminal exon came out soft-clipped instead of spliced: `61M2448N36M3S` where STAR writes `61M2448N36M2483N3M`, five points of AS lost on a uniquely-mapped read. Adding sjA to the key is the whole fix. The dedup still collapses what it exists to collapse, which the second test asserts. The block is now dedup_wa_by_diagonal so the two cases can be tested directly rather than through a full window stitch. Measured on the annotated-junction tier (1000 simulated reads over yeast annotated junctions, index rebuilt from this branch), raw exact records: 954 -> 958, and the four that changed were the only remaining differences that were not equal-score ties. On 10k real single-end reads 8793 -> 8796. Both tiers now differ from STAR only where the two tools found alignments of identical score at different loci.
Adds the four fixes that were not listed (one-base introns, annotated gaps never being deletions, the gLength primary tie-break, the sjA dedup key) and replaces the single measurement with a before/after table over the three tiers. Both columns are measured runs, each binary against an index built by that binary: main produces 361 yeast junctions and this branch 364, so comparing them against one shared index would have credited the branch with the wrong numbers. That is also why the note about rebuilding the index is there: a cached index reproduces main's results silently.
Three corrections to §1.1 and §5, all from measured runs on this branch. §1.1 said the default primary is "max score, smaller genomic length, earliest discovered, which is STAR-faithful". The first two keys are STAR's (ReadAlign_stitchPieces.cpp:340). The third is not: STAR takes the earliest window in its iteration order, this codebase takes the smallest genomic position. Calling that STAR-faithful was wrong. It also implied that matching STAR's window order would forfeit thread-invariance. It would not: STAR's window order is deterministic per read, and windows are built per read here too. The reason the positional key stays is that the alternative was tried and measured worse (+21 on the annotated-junction tier, -142 on 10k SE), because this codebase's anchor iteration order is not STAR's PC order. That is now what the entry says. §5 listed three residual reads that no longer exist. Replaced with the three that do, including one where rustar-aligner scores higher than STAR, which was not previously recorded anywhere.
`compare_seq_to_genome` returned "the query is greater" whenever it ran into a genome byte of 5. STAR's `compareSeqToGenome` does no such thing: it compares the raw bytes, and since read bases are 0-3 while `N` is 4 and the spacer is 5, a read base is never the greater one. Every branch of STAR's function therefore reports the query as sorting *before* such a suffix. That value is what orders the binary search in `max_mappable_length`, so it has to agree with the order the suffix array was built in. It did not, and the search read the array as unsorted at exactly the places where the sjdb inserts sit: a probe landing on a spacer inside an insert claimed the query was greater, the search dropped everything below that probe, and the entry holding the real maximum went with it. On ERR12389696.20597455 the suffix array holds a 29-base match at the right locus, one slot below a 24-base match inside an sjdb insert whose comparison hit a spacer. The MMP came back 24. The read then stitched two shorter pieces and placed its 1-base insertion four bases left of STAR's, `25M1I124M` against `29M1I120M`, at the same total score. Raw exact records on 10k yeast reads: single-end 8796 -> 8797, paired-end 16582 -> 16583. The annotated-junction tier is unchanged at 958/1000. Locked by `a_query_running_into_the_padding_sorts_before_that_suffix`.
Updates the measured table for the fix in e43ff5f (single-end 8796 to 8797, paired-end 16582 to 16583) and rewrites the residual list in DIVERGENCE.md §5 now that the insertion-placement read is gone. Two residuals remain on the paired-end tier, both characterised from runs rather than described from the code: ERR12389696.11539725, where rustar-aligner scores 224 against STAR's 235. Both mates cross the same junction, so the pair needs annotated junction 120 twice. Each half is reachable on its own (256 and 254) but the combination that would score 266 is not among the 84 transcripts the recursion enumerates. Raising alignTranscriptsPerWindowNmax changes nothing, so it is not the cap. Cause unknown; that is stated rather than guessed at. ERR12389696.4972950, where rustar-aligner scores 260 against STAR's 248 by splicing where STAR soft-clips. Both junctions are novel, so the only claim recorded is the score; neither alignment was checked against the transcript, and it stays in §5 rather than §2 for that reason.
ERR12389696.11539725 was recorded as "mechanism characterised, cause unknown". The cause is now traced. Both tools build the same window and agree on every align in it but one. STAR holds two aligns on a single diagonal, r172 g5083545 L11 and r174 g5083547 L9; rustar-aligner holds only the first. stitchAlignToTranscript.cpp:352 refuses to cross into the second mate when the mate's seed starts before the transcript's first exon, and that exon starts at g5083546, so the 11-base seed at g5083545 is one base too far left and only the 9-base seed at g5083547 can open the mate. STAR's extendAlign then walks it one base left onto g5083546. Without that align the two halves cannot be assembled, which is why the best rustar-aligner reaches are 256 and 254 where the pair would score 266. It is not a dedup difference. STAR's own assignAlignToWindow overlap test would collapse that pair, but it fires on insertion order, and a Gsj-crossing piece records its acceptor half at aRstart + aLengthD rather than at its own PC start. Reproducing it means reproducing STAR's PC order and its Gsj splits, which is the seed search, not the stitcher. Ruled out by measurement, not by reading: the per-window transcript cap (raising it to 10000 changes nothing, same 84 transcripts and 1460 recursions), qualitySplit (the pair has no N and no base below Q3, so both aligns share one fragment), and the diagonal dedup key. A rewrite of the dedup onto STAR's asymmetric overlap test was tried and reverted: its own test shows STAR's rule collapses the pair too when applied in sorted order, so the rewrite was not the fix it looked like.
The chaining DP scores an edge in Phase A with one function and the traceback re-stitches it with stitch_align_to_transcript, so the two can disagree. On a disagreement this returned an empty transcript vector, discarding the whole window. STAR does not. stitchWindowSeeds (ReadAlign_stitchWindowSeeds.cpp:130-138) calls stitchAlignToTranscript in its traceback and adds whatever it returns unconditionally; the `return` on failure is commented out and marked "this should not happen". Keep the chain assembled so far and let the score filters decide, which is what STAR's arithmetic amounts to. No effect on this branch alone: 500 simulated long reads against STARlong 2.7.11b give the same 419 primaries and 360 exact records before and after. It matters once the annotated-junction work in scverse#146 is in, because that adds rejection paths the traceback can hit. Measured on a local merge of the two branches: window discarded 407 primaries, 388 exact, 25 STAR-only chain kept 422 primaries, 388 exact, 10 STAR-only STAR aligns 430. Adds test/simulate_long_reads.py, which draws whole spliced transcripts as reads from a seeded RNG, so the fixture is byte-reproducible; the bundled 150 bp tiers never reach this path.
The chaining DP scores an edge in Phase A with one function and the traceback re-stitches it with stitch_align_to_transcript, so the two can disagree. On a disagreement this returned an empty transcript vector, discarding the whole window. STAR does not. stitchWindowSeeds (ReadAlign_stitchWindowSeeds.cpp:130-138) calls stitchAlignToTranscript in its traceback and adds whatever it returns unconditionally; the `return` on failure is commented out and marked "this should not happen". Keep the chain assembled so far and let the score filters decide, which is what STAR's arithmetic amounts to. No effect on this branch alone: 500 simulated long reads against STARlong 2.7.11b give the same 419 primaries and 360 exact records before and after. It matters once the annotated-junction work in scverse#146 is in, because that adds rejection paths the traceback can hit. Measured on a local merge of the two branches: window discarded 407 primaries, 388 exact, 25 STAR-only chain kept 422 primaries, 388 exact, 10 STAR-only STAR aligns 430. Adds test/simulate_long_reads.py, which draws whole spliced transcripts as reads from a seeded RNG, so the fixture is byte-reproducible; the bundled 150 bp tiers never reach this path.
Aligner-core faithfulness: annotated-junction stitching,
alignEndsType, and the genomic-length penalty inside the stitch recursion.What changed
sjAtags survive the seed pipeline.decode_gsj_hitcomputed the junction index and discarded it; it is now carried throughexpand_hit,WinCandidate,WindowAlignmentandExonBlock. The overlap dedup incluster_seedsgained STAR'se.sjA == sjAguard, so seeds from different annotated junctions are no longer collapsed for sharing a diagonal.The annotated shortcut. When the previous exon and the incoming piece carry the same tag, sit on the same mate and are read-adjacent, they are the two flanks of one junction that the Gsj insert split apart. They are stitched directly onto the annotated boundary with the stored motif, shifts and
sjdbScorebonus, skipping the general search.Annotated-boundary snapping. A non-canonical annotated junction is shifted by
shiftLeftonto its annotated coordinates instead of being left leftmost-flush, and the stored motif overrides the one re-derived from the genome. Both of STAR's-1000006rejections are reproduced.The genomic-length penalty moves inside the recursion, where STAR applies it, rather than being added in a post-pass.
--alignEndsTypeLocal,EndToEnd,Extend5pOfRead1,Extend5pOfReads12)--alignSoftClipAtReferenceEnds--alignInsertionFlushRight;Noneis bit-identical to before)--outFilterMismatchNoverReadLmax--alignTranscriptsPerReadNmax--alignEndsProtrude,--seedNoneLociPerWindow,--seedSplitMinWhy
STAR-RS-COMPARISON.md§7.2 lists these as the open faithfulness items and §7.4 names annotated-boundary snapping the best remaining lever.stitch.rsasked the annotation a yes/no question and, on a hit, swapped the motif penalty forsjdbScore; it never used what the annotation says about where the junction is. Item #2 stays dropped, as §7.5 records it regressing.No index format change
PreparedJunctionalready carriedmotif,shift_left,shift_rightandstrand;sjdbInfo.txtalready wrote and read all four;sort_and_dedupalready established the(stored_start, stored_end)orderingbinarySearch2needs.find()searches data that was already on disk. Existing indices load and align unmodified.The contents of a newly generated index do change, because of the one-base-intron fix below: yeast R64-1-1 now yields 364 junctions rather than 361, matching STAR. That matters for reproducing the numbers here and is called out under Verification.
One inconsistency is fixed along the way: the align-time
--sjdbGTFfilepath went throughfrom_raw_junctions, which discarded motif and shift, while the index-loaded path kept them. The three population paths now build identical tables. Where the index carries junctions, its array is thesjAtable, because that is the arraydecode_gsj_hitindexes.Verification against STAR 2.7.11b
Both columns are measured runs of the two binaries, each against an index built by that same binary. Sharing one index between them would credit this branch with main's junction set, or main with this branch's; the numbers move either way, so they are built separately. Data per CONTRIBUTING.md (yeast R64-1-1 release 110, ERR12389696,
seqtk sample -s100).Raw exact records, comparing FLAG, RNAME, POS, MAPQ, CIGAR, NH, AS and NM:
mainTie-adjusted, for comparison with earlier reports: junction tier 87.161% to 100.000%, SE 99.830% to 99.898%, PE 99.795% to 99.861%. Nothing regressed on any tier.
The junction tier is what makes the change visible at all. The existing tiers align real RNA-seq reads, few of which cross an intron, so the annotated-junction paths barely run. This PR adds
test/simulate_junction_reads.pyand ayeast_junction_gtftier: 1000 reads drawn from spliced transcript sequences, centred on annotated junctions, fixed seed, so the FASTQ is byte-reproducible.To reproduce, with
$DATAset up as CONTRIBUTING describes:A cached index will silently reproduce main's numbers. That is not hypothetical: the first run of this comparison used a stale index and reported 21 reads as an unfixed CIGAR bug when the code was already correct and only the fixture was old.
Five defects the tiers exposed, which reading the code had not
Each was found by measuring, and they compound, so each one hid the next.
genomeGeneratedropped one-base introns. The GTF junction extractor requiredintron_end > intron_start, which rejects an intron of length one. STAR skips a pair only when the exons touch or overlap (GTF_transcriptGeneSJ.cpp:123), soexon2.start == exon1.end + 2is a junction it keeps. Yeast R64-1-1 has three, and the index carried 361 junctions against STAR's 364; the three missing were exactly those.sjdbList.out.tabis now byte-identical to STAR's. Worth 21 reads on the tier.An annotated gap shorter than
--alignIntronMinwas scored and written as a deletion. STAR resolvessjdbIndbefore testingDel >= alignIntronMin(stitchAlignToTranscript.cpp:198-241): on a hit it takes the annotated branch whatever the gap length. Here the lookup sat inside the already-decided splice branch, so a short annotated gap never reached it. The CIGAR builder then re-derived the same decision from length alone, so even with the score fixed the operation stayedD. Both now ask through onegap_is_junction.The primary tie-break compared the junction count where STAR compares
gLength, the alignment's genomic span (ReadAlign_stitchPieces.cpp:340): a proxy for the span rather than the span itself. Using the span directly gains 6 SE reads and costs none.Window alignments split out of different annotated junctions were treated as duplicates of one another. STAR's
assignAlignToWindowguards its duplicate test with bothaFrag == WA[iA][WA_iFrag]andsjA == WA[iA][WA_sjA]. The pre-stitch diagonal dedup keyed on(diagonal, mate_id)only, so for a read whose last exon is a few bases long, the two entries covering the same donor-side bases (one tagged with the junction the read arrives on, one with the junction it leaves by) collapsed to the longest. That kept only the arriving tag, the micro-exon entry lost its same-sjApartner, and the terminal exon came out soft-clipped:Four uniquely-mapped reads on the tier, five points of AS each.
A genome spacer compared as the smaller byte in the seed search.
compare_seq_to_genomereturned "the query is greater" for any genome byte of 5. STAR'scompareSeqToGenomejust compares the raw bytes, and since read bases are 0-3 whileNis 4 and the spacer is 5, a read base is never the greater one: every branch reports the query as sorting before such a suffix.That value orders the binary search in
max_mappable_length, so it has to agree with the order the suffix array was built in. It did not, and the search read the array as unsorted exactly where the sjdb inserts sit. OnERR12389696.20597455the array holds a 29-base match at the right locus one slot below a 24-base match inside an insert whose comparison hit a spacer; the probe claimed the query was greater, the search dropped everything below it, and the MMP came back 24. The read then stitched two shorter pieces and placed its 1-base insertion four bases left of STAR's,25M1I124Magainst29M1I120M, at the same total score. This one is invisible on the annotated-junction tier and worth one read on each of SE and PE.Locked by
a_one_base_intron_is_a_junction_and_touching_exons_are_not,a_short_annotated_gap_is_a_junction_not_a_deletion,same_diagonal_entries_from_different_junctions_both_survive,same_diagonal_overlapping_entries_collapse_to_the_longestanda_query_running_into_the_padding_sorts_before_that_suffix.Two seed-search corrections that changed no output byte
Both are in the MMP chain loop, read from
ReadAlign_mapOneRead.cpp, and both are here because the search should be STAR's search, not because they moved a number.flagDirMap(:50,:61,:74): when the first left-to-right search of a good piece maps it to the piece end, STAR skips theistart == 0chain of the right-to-left direction. rustar-aligner always ran both. On these tiers the extra seed is a full-length duplicate thatstoreAlignswould drop anyway.The loop bound: STAR continues while
istart*Lstart + Lmapped + P.seedMapMin < splitR[1][ip], that is while strictly more thanseedMapMinbases remain; rustar-aligner continued while at least that many remained, running one extra search on a piece with exactlyseedMapMinleft. A 5-base MMP in a 12 Mb genome exceedsseedMultimapNmaxand is never stored, so nothing changed here either.Reported as no-ops rather than left out, because a reviewer should be able to tell which parts of this PR are load-bearing.
What is left
42 reads on the junction tier, 130 on SE and 197 mate records on PE still pick a different primary. Every one is a multimapper where both tools found alignments of identical score at different loci. On the junction tier and on SE that is now the only category left, which is what the 100.000% and 99.898% tie-adjusted figures mean.
Two PE pairs are not ties, and both are in
DIVERGENCE.md§5:ERR12389696.11539725scores 224 against STAR's 235, and traces to the seed search rather than the stitcher.Both tools build the same window and agree on every align in it but one. STAR holds two aligns on a single diagonal,
r172 g5083545 L11andr174 g5083547 L9; this branch holds only the first.stitchAlignToTranscript.cpp:352refuses to cross into the second mate when the mate's seed starts before the transcript's first exon, and that exon starts atg5083546, so the 11-base seed atg5083545is one base too far left and only the 9-base seed atg5083547can open the mate; STAR'sextendAlignthen walks it one base left ontog5083546. Without that align the two halves cannot be assembled, which is why the best reached here are 256 and 254 where the pair would score 266.It is not a dedup difference. STAR's own
assignAlignToWindowoverlap test would collapse that pair, but it fires on insertion order, and a Gsj-crossing piece records its acceptor half ataRstart + aLengthDrather than at its ownPCstart. Reproducing it means reproducing STAR'sPCorder and its Gsj splits.Ruled out by measurement rather than by reading: the per-window transcript cap (raising it to 10000 gives the same 84 transcripts and 1460 recursions),
qualitySplit(noNand no base below Q3 in the pair, so both aligns share one fragment), and the diagonal dedup key. Rewriting the dedup onto STAR's asymmetric overlap test was tried and reverted, because its own test shows STAR's rule collapses the pair too when applied in sorted order.ERR12389696.4972950scores 260 against STAR's 248, splicing1S33M72N50M186N65M1Swhere STAR soft-clips 27 bases. Both junctions are novel, not in the sjdb, so the only claim made is about the score; neither alignment has been checked against the transcript.I previously wrote in this PR that closing the tie category would cost thread-invariance. That was wrong and I retract it: STAR's window order is deterministic per read, and this codebase builds windows per read too, so reproducing it costs nothing in parallelism. I tried it (dropping the positional keys from the primary sort, relying on the stable sort to preserve discovery order) and measured +21 on the junction tier against -142 on SE. Net worse, so it is not in this PR. What that says is that this codebase's anchor iteration order is not STAR's
PCorder; matching it is a change to how MMP results are recorded, and it belongs in its own PR with its own before/after on both tiers.A note on the harness
The Homebrew macOS STAR 2.7.11b build silently reads zero reads from a gzipped FASTQ (
--readFilesCommand zcatproduced a 776-byte SAM with 0 alignments). Decompressing first and passing the plain FASTQ works. The CONTRIBUTING commands usezcat, so anyone reproducing this on macOS will hit it; on Linux it does not occur.Gate: 574 lib + 21 integration tests,
cargo clippy --all-targets -- -D warnings,cargo fmt --check, MSRV 1.89, all green. Thread invariance checked at--runThreadN1 against 16 on the 10k SE tier, byte-identical. Rebased onto currentmain.