feat: STARlong binary + long-read window-coverage filter - #116
Open
BenjaminDEMAILLE wants to merge 5 commits into
Open
feat: STARlong binary + long-read window-coverage filter#116BenjaminDEMAILLE wants to merge 5 commits into
BenjaminDEMAILLE wants to merge 5 commits into
Conversation
BenjaminDEMAILLE
force-pushed
the
bd/starlong
branch
from
July 28, 2026 22:35
989504b to
7346df8
Compare
Add rustar-aligner-long binary (STAR's STARlong: identical CLI, forces the chaining-DP long-read stitcher via Parameters::long_read, matching how native STAR selects it by binary rather than a flag). Wire params.long_read through stitch_seeds_with_jdb_debug into a new window_read_coverage (ReadAlign_stitchPieces.cpp's WC accumulation, faithful to upstream's rLast=0 under-count for a seed starting at read position 0) and gate the winReadCoverageRelativeMin/winReadCoverageBasesMin cluster filter on long_read, since standard STAR does not filter clusters by seed coverage. Refactor stitch_seeds_core's preamble into prepare_stitch_input, shared with the (upcoming) STARlong chaining-DP stitcher stitch_seeds_long_read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`extend_alignment` gained an `extend_to_end` argument when --alignEndsType landed. The two long-read chaining call sites pass `false`: they score a seed's own extension potential during chaining, and --alignEndsType applies at the read ends, not there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
stitchAlignToTranscript.cpp:308-315 gates the junction differently in the
two binaries:
#ifdef COMPILE_FOR_LONG_READS
if ( (trA->nMM + nMM) <= outFilterMismatchNmaxTotal )
#else
if ( (trA->nMM + nMM) <= outFilterMismatchNmaxTotal
&& ( jCan<0 || (jCan<7 && nMM <= P.alignSJstitchMismatchNmax[(jCan+1)/2]) ) )
#endif
The alignSJstitchMismatchNmax term is compiled out of STARlong. rustar
applied it in both, so a long read that reaches a junction with a
mismatch or two beside it lost the edge. The cost is not one junction:
the chaining DP then splits into a chain on each side of the junction,
and neither half clears --outFilterScoreMinOverLread, so the read comes
out unmapped.
That is what it was doing. On 500 simulated spliced long reads against
STARlong 2.7.11b, 13 reads were unmapped that STAR maps; 11 of them were
multi-exon mitochondrial transcripts, and one is an annotated 35-base
intron (Mito 24871-24905) rejected as non-canonical with a single
mismatch nearby.
before 419 primaries, 360 exact, 405 same-locus, 13 STAR-only
after 432 primaries, 372 exact, 423 same-locus, 1 STAR-only
STAR maps 430. The three rustar-only reads are 6-exon mitochondrial
transcripts it now aligns and STAR does not.
Adds the CHANGELOG entry the branch was missing and DIVERGENCE.md §2.2 for the three long reads rustar-aligner maps and STARlong does not. Those three are recorded as a divergence rather than fixed. STAR reports them as "unmapped: other", which is a failure to find a window rather than a decision to reject, and rustar-aligner places them on the mitochondrial transcript they were simulated from with the same exon structure STAR produces for other reads of that same transcript. Making them match would mean discarding correct alignments. Rebased onto current main, which restores DIVERGENCE.md: the branch predated it, so the diff read as deleting the file.
BenjaminDEMAILLE
force-pushed
the
bd/starlong
branch
from
July 29, 2026 19:37
cf8f010 to
bbda131
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
STARlong: the
rustar-aligner-longbinary, the long-read window-coverage filter, and two places where the long-read path did not match STARlong.Correction to the previous revision of this description
I wrote: "Not run: validation against real STARlong output. [...] I do not have a STARlong binary and a long-read dataset available."
The binary claim was wrong. STARlong 2.7.11b builds from the same source tree as STAR and was available the whole time. The differential is below, and running it found two defects that reading the code had not.
What changed
rustar-aligner-long, mirroring STAR's STARlong: identical CLI, forcing the long-read stitcher viaParameters::long_read. Native STAR selects this by binary rather than by flag, and this matches that.window_read_coverage, STAR'sWCaccumulation fromReadAlign_stitchPieces.cpp:208-220. The--winReadCoverageRelativeMin/--winReadCoverageBasesMincluster filter is gated onlong_read, since standard STAR does not filter clusters by seed coverage.--winReadCoverageBasesMinis new;--winReadCoverageRelativeMinalready existed with default 0.5 and becomes effective.The per-motif stitch mismatch cap no longer applies to long reads.
stitchAlignToTranscript.cpp:308-315gates the junction differently in the two binaries:The
alignSJstitchMismatchNmaxterm is compiled out of STARlong. Applying it anyway did not cost one junction, it cost whole reads: the chaining DP split into a chain either side of the rejected junction and neither half cleared--outFilterScoreMinOverLread. Traced onsim_12_Q0075, where the killed junction is the annotated 35-base intronMito 24871-24905, rejected as non-canonical because of a single mismatch beside it.A traceback edge that fails to re-stitch no longer discards its window. Phase A scores an edge with one function and the traceback re-stitches with
stitch_align_to_transcript, so the two can disagree. This returned an empty transcript vector on a disagreement. STAR does not:stitchWindowSeeds(ReadAlign_stitchWindowSeeds.cpp:130-138) adds whateverstitchAlignToTranscriptreturns unconditionally, with the failurereturncommented out and marked "this should not happen".One faithful defect, kept
STAR initialises
rLast = 0before the coverage loop (ReadAlign_stitchPieces.cpp:208) and then testsr1 + L1 > rLast + 1, so a seed starting at read position 0 is counted one base short. That is reproduced rather than corrected: the filter thresholds are calibrated against STAR's own numbers, so fixing the count in isolation would change which windows survive relative to the oracle.Verification against STARlong 2.7.11b
The bundled tiers are 150 bp Illumina reads and never reach this path: a short read produces one window with near-total coverage, so the coverage filter can discard nothing. This PR adds
test/simulate_long_reads.py, which emits the full spliced sequence of each multi-exon transcript as one read, with substitution errors from a seeded RNG so the FASTQ is byte-reproducible. 500 reads, mean 2.1 kb, longest 5.6 kb, every read multi-exon.Both tools run single-threaded with STAR's documented long-read parameter set. STAR maps 430 of 500.
Exact records compare FLAG, RNAME, POS, MAPQ, CIGAR, NH and AS.
Two notes on that command, both found by running it.
--alignTranscriptsPerWindowNmax 10000with the default--alignTranscriptsPerReadNmaxmaps zero reads, in STAR as well: STAR's headroom break istrNtotal + alignTranscriptsPerWindowNmax >= alignTranscriptsPerReadNmax, which is already true on the first window, and STARlong logs 435 such warnings and writes an empty SAM. STAR's own recommended set raises both, so it does not hit this. It is not a rustar-aligner defect, and I checked before assuming it was.STARlong at
--runThreadN 8wrote an empty SAM here while exiting 0. Single-threaded it is fine. That is the STAR binary or my harness, not rustar-aligner, and both sides were run single-threaded so the comparison is like for like.What is left
One read STAR maps and this does not,
sim_403_YER179W: STAR emits132M92N873M, and the chain built here covers only 240 of 1005 bases. The last two exons are at the right locus (V:548432, V:548645), but the window holds no seed for read positions 240 onward; those seeds went to unrelated clusters. Disabling the coverage filter changes nothing, so it is seed distribution rather than the stitcher or the filter. That is the samestoreAlignspiece-ordering work already named in #146, not something this PR can close.Three reads this maps and STAR does not. STAR reports them
unmapped: other, which is a failure to find a window rather than a rejection, and they land on the mitochondrial transcript they were simulated from with the exon structure STAR itself produces for other reads of that transcript. Recorded inDIVERGENCE.md§2.2 rather than corrected.Nine reads where AS is lower than STAR's, and all nine belong to #146, not here: seven are the one-base annotated intron on
YOR239Wwritten as1Dwhere STAR writes1N, and two are a terminal micro-exon soft-clipped instead of spliced. Both classes are fixed in #146. On a local merge of the two branches, exact records rise from 372 to 402; that merge is mine and not either PR, so the number is indicative rather than a claim about this diff.Verification
Unit tests in
src/align/read_align.rs:window_read_coverage_single_seed: a 10-base seed at read position 0 counts 9, pinning the undercount abovewindow_read_coverage_seed_not_at_zero: the same seed at position 5 counts 10, showing the undercount is confined to position 0window_read_coverage_union_of_overlapping_seeds: overlapping seeds count their union, not their sumwindow_read_coverage_disjoint_seedsIn
src/align/score.rs:the_stitch_mismatch_cap_does_not_apply_to_long_readsthe_scorer_takes_long_read_from_the_parametersGate: 568 lib + integration tests,
cargo clippy --all-targets -- -D warnings,cargo fmt --check, all green. Rebased onto currentmain, which also restoresDIVERGENCE.md: the branch predated that file, so the diff previously read as deleting it.