Skip to content

aligner core: annotated-junction stitching, alignEndsType, in-recursion length penalty - #146

Open
BenjaminDEMAILLE wants to merge 17 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/aligner-core-faithfulness
Open

aligner core: annotated-junction stitching, alignEndsType, in-recursion length penalty#146
BenjaminDEMAILLE wants to merge 17 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/aligner-core-faithfulness

Conversation

@BenjaminDEMAILLE

@BenjaminDEMAILLE BenjaminDEMAILLE commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Aligner-core faithfulness: annotated-junction stitching, alignEndsType, and the genomic-length penalty inside the stitch recursion.

What changed

sjA tags survive the seed pipeline. decode_gsj_hit computed the junction index and discarded it; it is now carried through expand_hit, WinCandidate, WindowAlignment and ExonBlock. The overlap dedup in cluster_seeds gained STAR's e.sjA == sjA guard, 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 sjdbScore bonus, skipping the general search.

Annotated-boundary snapping. A non-canonical annotated junction is shifted by shiftLeft onto 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 -1000006 rejections are reproduced.

The genomic-length penalty moves inside the recursion, where STAR applies it, rather than being added in a post-pass.

Flag Status
--alignEndsType implemented (Local, EndToEnd, Extend5pOfRead1, Extend5pOfReads12)
--alignSoftClipAtReferenceEnds implemented
--alignInsertionFlush implemented (Right; None is bit-identical to before)
--outFilterMismatchNoverReadLmax implemented
--alignTranscriptsPerReadNmax implemented (STAR's headroom break)
--alignEndsProtrude, --seedNoneLociPerWindow, --seedSplitMin accepted and validated

Why

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.rs asked the annotation a yes/no question and, on a hit, swapped the motif penalty for sjdbScore; 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

PreparedJunction already carried motif, shift_left, shift_right and strand; sjdbInfo.txt already wrote and read all four; sort_and_dedup already established the (stored_start, stored_end) ordering binarySearch2 needs. 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 --sjdbGTFfile path went through from_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 the sjA table, because that is the array decode_gsj_hit indexes.

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:

tier records main this branch
junction-spanning yeast reads 1000 835 958
10k single-end yeast reads 8927 8785 8797
10k paired-end yeast reads 16782 16572 16583

Tie-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.py and a yeast_junction_gtf tier: 1000 reads drawn from spliced transcript sequences, centred on annotated junctions, fixed seed, so the FASTQ is byte-reproducible.

To reproduce, with $DATA set up as CONTRIBUTING describes:

python3 test/simulate_junction_reads.py \
  "$DATA/reference/Saccharomyces_cerevisiae.R64-1-1.dna.toplevel.fa" \
  "$DATA/reference/Saccharomyces_cerevisiae.R64-1-1.110.gtf" \
  "$DATA/reads/sim_junction_1k.fastq" 1000 150
# rebuild the rustar-aligner index with THIS branch's binary, then align
# with STAR and with rustar-aligner against their own GTF-built indices:
python3 test/assess_faithfulness.py --se-only \
  --se-star "$DATA/star_junc/Aligned.out.sam" \
  --se-rustar-aligner "$DATA/rustar_junc/Aligned.out.sam"

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.

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), so exon2.start == exon1.end + 2 is 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.tab is now byte-identical to STAR's. Worth 21 reads on the tier.

An annotated gap shorter than --alignIntronMin was scored and written 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. 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 stayed D. Both now ask through one gap_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 assignAlignToWindow guards its duplicate test with both aFrag == WA[iA][WA_iFrag] and sjA == 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-sjA partner, and the terminal exon came out soft-clipped:

STAR              61M2448N36M2483N3M   AS:i:101
before this fix   61M2448N36M3S        AS:i:96

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_genome returned "the query is greater" for any genome byte of 5. STAR's compareSeqToGenome just 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 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. On ERR12389696.20597455 the 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, 25M1I124M against 29M1I120M, 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_longest and a_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 the istart == 0 chain of the right-to-left direction. rustar-aligner always ran both. On these tiers the extra seed is a full-length duplicate that storeAligns would drop anyway.

The loop bound: STAR continues while istart*Lstart + Lmapped + P.seedMapMin < splitR[1][ip], that is while strictly more than seedMapMin bases remain; rustar-aligner continued while at least that many remained, running one extra search on a piece with exactly seedMapMin left. A 5-base MMP in a 12 Mb genome exceeds seedMultimapNmax and 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.11539725 scores 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 L11 and r174 g5083547 L9; this branch 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 reached here 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.

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 (no N and 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.4972950 scores 260 against STAR's 248, splicing 1S33M72N50M186N65M1S where 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 PC order; 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 zcat produced a 776-byte SAM with 0 alignments). Decompressing first and passing the plain FASTQ works. The CONTRIBUTING commands use zcat, 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 --runThreadN 1 against 16 on the 10k SE tier, byte-identical. Rebased onto current main.

BenjaminDEMAILLE and others added 8 commits July 29, 2026 11:56
…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>
@BenjaminDEMAILLE
BenjaminDEMAILLE force-pushed the bd/aligner-core-faithfulness branch from 3661533 to 8a0a47a Compare July 29, 2026 10:07
…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.
@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor Author

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 are

37 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 maxScore, then gLength, then whichever window it reached first (ReadAlign_stitchPieces.cpp:340). The first two are now matched. The third is window iteration order.

Why I thought that was closed, and why it is not

I 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 cluster_seeds already produces and the dedup already preserves.

The measurement

positional keys (current) discovery order
junction tier, raw exact 951 972
SE 10k, raw exact 8797 8655

+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 PC order. Where the two happen to agree, discovery order is strictly better — hence the tier gain. Where they do not, it is worse than an arbitrary-but-stable positional rule.

What reaching exact parity would take

Reproducing STAR's storeAligns piece ordering — the order in which MMP search results are recorded, which decides anchor order, which decides window order. That is a change to the seed-search side, not the stitcher, and it should be measured on both tiers before being believed. It is not a tie-break tweak.

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 --runThreadN 1 and 16 produce byte-identical SAM.

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.
BenjaminDEMAILLE added a commit to BenjaminDEMAILLE/rustar-aligner that referenced this pull request Jul 29, 2026
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.
BenjaminDEMAILLE added a commit to BenjaminDEMAILLE/rustar-aligner that referenced this pull request Jul 29, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant