Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,36 @@ Sections commonly used: Features, Bug fixes, Other changes.

### Features

- **`rustar-aligner-long`, mirroring STAR's STARlong**, with the long-read
window-coverage filter (`--winReadCoverageRelativeMin`, and the new
`--winReadCoverageBasesMin`). STAR selects the long-read path by binary
rather than by flag, and so does this.

Two places where the long-read path diverged from STARlong and now does not:

- **The per-motif stitch mismatch cap does not apply.**
`stitchAlignToTranscript.cpp:308-315` keeps only the total mismatch budget
under `COMPILE_FOR_LONG_READS` and puts `alignSJstitchMismatchNmax` in the
`#else`, so it is compiled out of STARlong. Applying it cost whole reads,
not single junctions: the chaining DP split into a chain either side of the
rejected junction and neither half cleared
`--outFilterScoreMinOverLread`.
- **A traceback edge that fails to re-stitch no longer discards its window.**
STAR's `stitchWindowSeeds` adds whatever `stitchAlignToTranscript` returns
without checking, its failure `return` commented out and marked "this
should not happen".

On 500 simulated spliced long reads against STARlong 2.7.11b (STAR maps 430
of 500):

| | primaries | exact records | same locus | unmapped by rustar |
|---|---|---|---|---|
| before | 419 | 360 | 405 | 13 |
| after | 432 | 372 | 423 | 1 |

Three of the extra primaries are reads STARlong leaves unmapped and
rustar-aligner places correctly; see `DIVERGENCE.md` §2.2.

- **STARsolo single-cell quantification (`--soloType`)** — the 10x
Chromium / plate-based count-matrix pipeline, ported from STAR and
verified against real STARsolo (#90).
Expand Down
6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ exclude = [
name = "rustar-aligner"
path = "src/main.rs"

[[bin]]
# STAR's `STARlong` (`-DCOMPILE_FOR_LONG_READS`): identical CLI, but forces the
# chaining-DP long-read stitcher (see params::Parameters::long_read).
name = "rustar-aligner-long"
path = "src/bin/rustar_aligner_long.rs"

[dependencies]
clap = { version = "4", features = ["derive"] }
anyhow = "1"
Expand Down
19 changes: 19 additions & 0 deletions DIVERGENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ On the 10k yeast PE benchmark, 4 reads differ in alignment score (AS) because ST

**Source.** See `CLAUDE.md` (PE status) and `STAR-RS-COMPARISON.md`.

### 2.2 Three long reads STARlong does not map at all

On 500 simulated spliced long reads (`test/simulate_long_reads.py`, mean 2.1 kb, every read multi-exon), `rustar-aligner-long` maps three that STARlong 2.7.11b leaves unmapped: `sim_371_Q0105_mRNA_6ex`, `sim_448_Q0105_mRNA_6ex` and `sim_476_Q0115_mRNA_3ex`.

**What STAR does.** It reports all three as `unmapped: other`, not as filtered: `Log.final.out` shows 0 too-short, 0 too-many-mismatches, 3 other. That marker means no window produced a usable transcript, so this is a failure to find the alignment rather than a decision to reject it.

**What rustar-aligner does.** It places them on the mitochondrial transcript they were drawn from, with the same exon structure STAR itself produces for other reads of that transcript:

```
STAR sim_140_Q0105 Mito:36540 415M768N14M1404N77M1623N250M1417N46M738N356M
rustar sim_371_Q0105 Mito:36540 415M768N28M1404N63M1623N250M1417N50M738N352M
```

**Why.** These reads are simulated from `Q0105` and `Q0115`, so the locus and the junction set are known, and both match. The alignments are correct.

**Impact.** Three reads on this tier count against raw exactness while being improvements. Not corrected: matching STAR here would mean discarding correct alignments to reproduce a failure.

**Source.** `test/simulate_long_reads.py` for the fixture; STARlong `Log.final.out` for the unmapped classification.

---

## 3. Output-file metadata divergences
Expand Down
103 changes: 98 additions & 5 deletions src/align/read_align.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
use crate::align::score::{AlignmentScorer, SpliceMotif};
use crate::align::seed::Seed;
use crate::align::stitch::{
PE_SPACER_BASE, cluster_seeds, finalize_transcript, split_combined_wt, stitch_seeds_core,
stitch_seeds_with_jdb_debug,
PE_SPACER_BASE, SeedCluster, cluster_seeds, finalize_transcript, split_combined_wt,
stitch_seeds_core, stitch_seeds_with_jdb_debug,
};
use crate::align::transcript::{Exon, Transcript};
use crate::error::Error;
Expand Down Expand Up @@ -156,6 +156,30 @@ pub enum PairedAlignmentResult {
/// - n_for_mapq: effective alignment count for MAPQ calculation (max of transcript count
/// and valid cluster count, to avoid undercounting from coordinate dedup on tandem repeats)
/// - unmapped_reason: `Some(reason)` if no alignments produced, `None` if mapped
///
/// STARlong-only per-window read-space seed coverage (`ReadAlign_stitchPieces.cpp`'s
/// `WC` accumulation): the union, over the window's seeds sorted by read position, of newly
/// covered read bases. Faithful to upstream's `rLast=0` initialization, which under-counts a
/// seed starting at read position 0 by one base.
fn window_read_coverage(cluster: &SeedCluster) -> u64 {
let mut wa: Vec<&crate::align::stitch::WindowAlignment> = cluster.alignments.iter().collect();
wa.sort_by_key(|a| a.read_pos);
let mut cov = 0u64;
let mut r_last = 0u64;
for a in &wa {
let (r1, l1) = (a.read_pos as u64, a.length as u64);
if r1 + l1 > r_last + 1 {
cov += if r1 > r_last {
l1
} else {
r1 + l1 - (r_last + 1)
};
r_last = r1 + l1 - 1;
}
}
cov
}

pub fn align_read(
read_seq: &[u8],
read_name: &str,
Expand Down Expand Up @@ -272,9 +296,23 @@ pub fn align_read(
let mut clusters = clusters;
clusters.truncate(params.align_windows_per_read_nmax);

// NOTE: STAR's winReadCoverageRelativeMin filter is long-reads-only
// (#ifdef COMPILE_FOR_LONG_READS in stitchPieces.cpp). Standard STAR
// does NOT filter clusters by seed coverage. Removed to match STAR.
// STARlong-only (`-DCOMPILE_FOR_LONG_READS`): drop windows whose read-space seed
// coverage falls below both a relative (of the best window) and an absolute floor.
// Inert for the short-read `rustar-aligner` binary (winReadCoverageRelativeMin's
// default 0.5 only applies once `params.long_read` is set).
if params.long_read {
let coverage: Vec<u64> = clusters.iter().map(window_read_coverage).collect();
let cov_max = coverage.iter().copied().max().unwrap_or(0);
let cov_min = cov_max as f64 * params.win_read_coverage_relative_min;
clusters = clusters
.into_iter()
.zip(coverage)
.filter(|(_, cov)| {
(*cov as f64) >= cov_min && *cov >= params.win_read_coverage_bases_min
})
.map(|(c, _)| c)
.collect();
}

// Step 2b: Detect chimeric alignments from multi-cluster seeds (Tier 2)
let mut chimeric_alignments = Vec::new();
Expand Down Expand Up @@ -307,6 +345,7 @@ pub fn align_read(
&scorer,
junction_db,
params.align_transcripts_per_window_nmax,
params.long_read,
debug_name,
);
if debug_read {
Expand Down Expand Up @@ -2300,4 +2339,58 @@ mod tests {
shuffle_tied_prefix(&mut items, |t| t.0, 42);
assert_eq!(items, before);
}

fn wa(read_pos: usize, length: usize) -> crate::align::stitch::WindowAlignment {
crate::align::stitch::WindowAlignment {
seed_idx: 0,
read_pos,
length,
genome_pos: read_pos as u64,
sa_pos: read_pos as u64,
n_rep: 1,
is_anchor: true,
mate_id: 2,
pre_ext_score: 0,
}
}

fn cluster_with(alignments: Vec<crate::align::stitch::WindowAlignment>) -> SeedCluster {
SeedCluster {
alignments,
chr_idx: 0,
genome_start: 0,
genome_end: 0,
is_reverse: false,
anchor_idx: 0,
anchor_bin: 0,
}
}

#[test]
fn window_read_coverage_single_seed() {
// One seed covering read[0..10): the `rLast=0` init under-counts a seed starting
// at read position 0 by one base (faithful to STAR's own quirk).
let cluster = cluster_with(vec![wa(0, 10)]);
assert_eq!(window_read_coverage(&cluster), 9);
}

#[test]
fn window_read_coverage_seed_not_at_zero() {
// A seed starting after position 0 is not subject to the rLast=0 undercount.
let cluster = cluster_with(vec![wa(5, 10)]);
assert_eq!(window_read_coverage(&cluster), 10);
}

#[test]
fn window_read_coverage_union_of_overlapping_seeds() {
// Two overlapping seeds: coverage is the union of read bases, not the sum.
let cluster = cluster_with(vec![wa(5, 10), wa(10, 10)]);
assert_eq!(window_read_coverage(&cluster), 15);
}

#[test]
fn window_read_coverage_disjoint_seeds() {
let cluster = cluster_with(vec![wa(0, 5), wa(20, 5)]);
assert_eq!(window_read_coverage(&cluster), 9);
}
}
Loading
Loading