Skip to content

feat: paired-end alignment with adapter read-through detection - #3

Merged
jayhesselberth merged 3 commits into
mainfrom
feat/paired-end
Oct 4, 2026
Merged

jayhesselberth merged 3 commits into
mainfrom
feat/paired-end

Conversation

@jayhesselberth

Copy link
Copy Markdown
Member

Closes #1.

What

Paired-end alignment: -1/-2, --interleaved, or a BAM of 0x1/0x40/0x80 mates align read pairs as pairs.

  • Pair-aware resolution (eschalign::Aligner::map_pairs): candidate = (reference, template strand); score = mate 1 + mate 2 (R2 reverse-complemented), both mates >= --min-score. Candidates are traced a score tier at a time; the first tier containing a candidate that passes the constraints (no outie, insert <= --max-insert default 500, both mates align a base) is the tie set. No qualifying candidate: written without 0x2, each mate at its own best reference on either strand.
  • Adapters (eschalign::adapter): TruSeq R1/R2 prefix detected on each mate's 3' end first (--adapter-r1/-r2, --min-adapter-overlap default 5, --no-adapter-trim); only the bases before it are aligned, the adapter stays as soft clip with ad:i; nothing is discarded.
  • Standard paired SAM output: flags, RNEXT/PNEXT/TLEN, MC, per-mate NM/MD/AS/XS/XA, MAPQ 60 only for a unique best reference, mates adjacent in input order; --sort coordinate and --secondary work.
  • Pipeline: cut_chunks never cuts between mates (pair-in-one-chunk), no barrier added; the GPU thread scores flattened mates with the same adapter trimming as the workers.
  • Unpaired path untouched: unpaired_output_unchanged pins a digest of the uncompressed record stream over 6 flag combinations, blessed from the binary at the commit before this branch.

Decisions on the open details

  • ad:i: each mate's own adapter length; present (0 if none) on every primary record of a paired run that searched, absent under --no-adapter-trim. One mate reading through: it carries the count, the other ad:i:0. The two mates must agree where the insert ends (len - ad equal) or the weaker detection is dropped (both on a tie); checked in insert coordinates before alignment (the same quantity as in the reference frame, keeps the DP input fixed).
  • Pair-level XA: not both mates' positions. Each mate's XA lists that mate's alignment in every other tied pair, in bwa's grammar, same order on both mates (entry k of each = tied pair k). --secondary writes the same ties as 0x100 records, mate 1 then mate 2 per pair.
  • --min-score: both mates must clear it, not just the sum.
  • Mode: paired runs default to --mode semiglobal (local clips terminal mismatches, which is what the issue wants kept); an explicit --mode local is honoured with a warning. Consequence, documented: semi-global gives every read some positive score, so paired runs need --min-score (all tests pass one).
  • Orientation / strands: proper = FR with mate 1 upstream; --strand both adds the reverse template strand. FF/RR are never candidates. Fallback mates are mapped on both strands.
  • TLEN = leftmost to rightmost aligned base over both mates (the true insert), signed, also set for improper same-reference pairs. An unmapped mate sits at its partner's position (samtools convention).
  • BAM detection: a BAM whose first primary record is 0x1 with 0x40/0x80 is a paired run. That is a behaviour change for such a BAM only (it used to run as unpaired reads); noted in the changelog. Everything else is unchanged.
  • Mates over --max-read-len: that mate unmapped, partner aligned alone.

Tests

  • cargo fmt --all --check: clean
  • cargo clippy --workspace --all-targets -- -D warnings and with --features gpu: clean
  • cargo nextest run --workspace (rna node): 120 passed, 0 failed
  • cargo nextest run --features gpu --workspace (gpu node): 126 passed, 0 failed, including pair_gpu_matches_cpu (confirmed it ran on the device, not skipped)
  • cargo test --doc --workspace: no failures
  • All 11 expected tests are present: pair_resolves_paralog_tie, pair_insert_shorter_than_read, pair_terminal_mismatch_not_clipped, pair_over_max_insert_not_proper, pair_wrong_orientation_not_proper, pair_inputs_agree_fastq_interleaved_ubam, pair_orphan_is_error, pair_adapter_tag_counts, pair_gpu_matches_cpu, pair_sort_coordinate_matches_samtools (against real samtools sort), unpaired_output_unchanged; plus pair_tie_is_reported_in_xa_and_secondaries, pair_ubam_tags_are_copied_on_both_mates, pair_chunking_keeps_mates_together, and unit tests for adapter and mapper::pair.
  • Fixture: tests/fixtures/pairs/ (4 references, 9 hand-built pairs, 5 KB, regenerable with gen_pair_fixture.py); no pod5, no real data.

Not verified

  • No real Illumina snRNA data was run; behaviour on real adapter/indel noise is untested. The adapter thresholds (min overlap 5, 10% error) are defaults, not tuned.
  • Performance of the paired path was not measured (speed is a non-goal; every mate is scored once, tiers are traced as needed, and adapter-only pairs that tie every reference trace the whole panel).
  • Multi-flowcell-scale chunking was exercised with 300 pairs on tiny batches, not a large run.

@jayhesselberth
jayhesselberth merged commit 59e101e into main Oct 4, 2026
5 checks passed
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.

Paired-end input: pair-aware resolution, --max-insert, adapter handling

1 participant