From fd3eb5fff56933217c2449ee76fe602c758cced4 Mon Sep 17 00:00:00 2001 From: Psy-Fer Date: Tue, 21 Jul 2026 13:57:54 +1000 Subject: [PATCH 1/6] v0.3.0: fix get_read_cuts boundary bug, add soft-clip fields, bump noodles Coordinate-math correctness pass driven by a differential test against bladerunner, aimed at letting bladerunner link bedpull as a library. - Fix boundary-coincidence bug in get_read_cuts: a read whose alignment began/ended exactly at a region boundary was mis-sliced or silently dropped (BAM/CRAM path). Replace the `start > 0` sentinel with an explicit found_start flag + op-entry guard; the op-level `== ref_end` shortcut no longer skips a mid-op ref_start crossing. - get_read_cuts now takes align_end (exclusive) and clamps its fire-on boundaries to the alignment span internally, so partial reads return a real slice instead of a read_end == 0 sentinel. - ReadCuts gains softclip_lead_start / softclip_trail_end, making its layout field-identical to bladerunner's ReadCuts. - Simplify resolve_cuts: spanning is decided by align coverage, not by un-swapping sentinel fields. Real-data BAM/CRAM tests unchanged (40 spanning / 44 partial). - Bump noodles 0.110 -> 0.111 to match bladerunner across the boundary. - Add regression battery for the boundary cases and soft-clip fields; update CHANGELOG and version. --- CHANGELOG.md | 34 ++++ Cargo.lock | 18 +-- Cargo.toml | 4 +- src/main.rs | 25 ++- src/reads.rs | 230 +++++++++++---------------- src/utils.rs | 371 +++++++++++++++++++++++++++++++++---------- tests/integration.rs | 6 +- 7 files changed, 456 insertions(+), 232 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea7e11..620fda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ This project uses [Semantic Versioning](https://semver.org/). --- +## [0.3.0] — unreleased + +Coordinate-math correctness pass, driven by a differential test against +[bladerunner](https://github.com/Psy-Fer/bladerunner) and aimed at letting bladerunner link +`bedpull` as a library. + +### Fixed + +- **Boundary-coincidence bug in `get_read_cuts`** — a read whose alignment began (or ended) + exactly at a region boundary was mis-sliced or silently dropped. The `start > 0` sentinel + conflated "start not found yet" with "start found at read offset 0". Replaced with an explicit + `found_start` flag plus an op-entry guard, and the op-level `== ref_end` shortcut no longer + skips a mid-op `ref_start` crossing. This affected BAM/CRAM extraction (the PAF path already + routed around it via `read_pos_at_ref`). + +### Changed + +- **`get_read_cuts` signature** is now + `get_read_cuts(cigar_ops, align_start, align_end, region_start, region_end)`. It takes the + alignment end and clamps its fire-on boundaries to the alignment span internally + (`ref_start = max(region_start, align_start)`, `ref_end = min(region_end, align_end)`), so a + partially-overlapping read yields a real slice instead of a `read_end == 0` sentinel. + `region_start`/`region_end` are the desired (flank-expanded) window; the caller no longer + pre-clamps. **`align_end` is exclusive (one past the last reference base)** — callers using + noodles' inclusive `alignment_end()` must add 1. +- **`ReadCuts` gains `softclip_lead_start` and `softclip_trail_end`** (read offsets of the + leading/trailing soft-clip runs; equal to `read_start`/`read_end` when no extension is + available). Layout is now field-identical to bladerunner's `ReadCuts`. +- **noodles bumped `0.110` → `0.111`** to match bladerunner across the crate boundary. + +### Added + +- Regression battery for the boundary-coincidence cases and the soft-clip extension fields. + ## [0.2.0] — 2026-07-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index e860f70..b0c5f38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "bedpull" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -538,9 +538,9 @@ dependencies = [ [[package]] name = "noodles" -version = "0.110.0" +version = "0.111.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34e7ed524a472dbd0dc69cc7b63408f37552e48460028af44a3024f2eb77f036" +checksum = "78906b00d2b2d144c920567724ab0dc68ef8da7fc258ef18da86bbbec572000e" dependencies = [ "noodles-bam", "noodles-bed", @@ -554,9 +554,9 @@ dependencies = [ [[package]] name = "noodles-bam" -version = "0.89.0" +version = "0.90.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c1872afd5963f751ae78ef04ab3fb050aefc615c05c04cda7c54120eedf9b0" +checksum = "4d319ea3e4414172455eec82f0283ae3fc6a5a8e9b23bdc16ee426986a615094" dependencies = [ "bstr", "indexmap", @@ -604,9 +604,9 @@ dependencies = [ [[package]] name = "noodles-cram" -version = "0.93.0" +version = "0.94.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae197edf3fbf530f6cbd4cf22dc36dba94b1a14d6ed1a925bd17e0eaecc40e8" +checksum = "267b2934c706c2372af2eefde6c936cc5fbf26a899f5844cc48d62ea51cbd933" dependencies = [ "bitflags", "bstr", @@ -637,9 +637,9 @@ dependencies = [ [[package]] name = "noodles-fasta" -version = "0.61.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0e6a772d7a5cb566196d1a1806c4471ad63bc6ffad5b2d549e9602708a9aaab" +checksum = "c36aecf3899ba8ca698bddee3c7264d6e70afca6620524ece230d2c34b93021b" dependencies = [ "bstr", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 93d7d28..e892c9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bedpull" -version = "0.2.0" +version = "0.3.0" edition = "2024" authors = ["James M. Ferguson "] repository = "https://github.com/Psy-Fer/bedpull" @@ -22,7 +22,7 @@ tempfile = "3" [dependencies] clap = { version = "4", features = ["derive", "cargo", "wrap_help"] } -noodles = { version = "0.110.0", features = ["core", "bgzf", "bam", "sam", "bed", "fasta", "fastq", "cram"] } +noodles = { version = "0.111.0", features = ["core", "bgzf", "bam", "sam", "bed", "fasta", "fastq", "cram"] } itertools = "0.11" anyhow = "1.0" csv = "1.3" diff --git a/src/main.rs b/src/main.rs index 58081ef..7fe5e17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -854,10 +854,33 @@ mod tests { #[test] fn get_read_cuts_rfc1_captures_insertion() { + use noodles::sam::alignment::record::cigar::op::Kind; let r = read_paf_record_at_offset(PAF_PATH, 0).unwrap(); let cigar_str = r.cigar.as_deref().expect("no CIGAR"); let ops = cigar_str.to_cigar_ops().expect("valid CIGAR from PAF file"); - let cuts = get_read_cuts(&ops, RFC1_TARGET_START, RFC1_REGION_START, RFC1_REGION_END); + // align_end = target_start + reference bases consumed (M/=/X/D/N). + let ref_len: usize = ops + .iter() + .filter(|op| { + matches!( + op.kind, + Kind::Match + | Kind::SequenceMatch + | Kind::SequenceMismatch + | Kind::Deletion + | Kind::Skip + ) + }) + .map(|op| op.len) + .sum(); + let align_end = RFC1_TARGET_START + ref_len; + let cuts = get_read_cuts( + &ops, + RFC1_TARGET_START, + align_end, + RFC1_REGION_START, + RFC1_REGION_END, + ); let extracted_len = cuts.read_end - cuts.read_start; assert_eq!( extracted_len, RFC1_EXPECTED_BP, diff --git a/src/reads.rs b/src/reads.rs index ef7ccc1..b41e50c 100644 --- a/src/reads.rs +++ b/src/reads.rs @@ -251,14 +251,11 @@ where return Ok(None); }; - // Deliberately not get_read_cuts: it tracks two boundaries per call and - // decides which slot to write into based on whether the first has - // already fired, a convention that breaks when a boundary coincides - // exactly with a record's own align_start (see read_pos_at_ref's docs). - // That's not a hypothetical here — desired_start legitimately does equal - // first.target_start whenever the window's left edge lines up exactly - // with the first chain member's own start (same for desired_end and - // last.target_end), so each boundary is resolved independently instead. + // Deliberately not get_read_cuts: the two window edges live on *different* + // chained records here (desired_start on the first record's CIGAR, + // desired_end on the last's), so each is resolved independently with + // read_pos_at_ref. get_read_cuts models a single alignment's two boundaries + // and doesn't fit a boundary-per-record split. let (Some(read_pos_first), Some(read_pos_last)) = ( read_pos_at_ref(&first_ops, first.target_start, desired_start), read_pos_at_ref(&last_ops, last.target_start, desired_end), @@ -336,50 +333,36 @@ where } /// Resolve the final `(read_start, read_end, ref_start, ref_end)` for a read from -/// its raw [`ReadCuts`], applying partial-overlap fallback rules. +/// its raw [`ReadCuts`]. /// -/// `get_read_cuts` fires on `ref_pos == region_start` and `ref_pos == region_end`. When -/// the alignment starts after `region_start`, the `region_start` trigger never fires; -/// `read_cuts.ref_start`/`read_cuts.read_start` end up holding the `region_end` position -/// instead (see [`get_read_cuts`][crate::utils::get_read_cuts] docs), and `read_end`/`ref_end` -/// stay `0`. This function undoes that mislabelling so callers get the reference span -/// actually covered by the returned slice, not the raw (and sometimes swapped) fields. -/// Returns `None` when the read doesn't satisfy the region and `config.partial` is `false` -/// (the read should be skipped). +/// [`get_read_cuts`][crate::utils::get_read_cuts] now clamps the fire-on boundaries +/// to the alignment span itself, so `read_start`/`read_end` are already correct read +/// offsets and `ref_start`/`ref_end` already report the covered sub-span — no +/// sentinel un-swapping is needed. This function is left as the one place the +/// spanning/partial *policy* is applied: +/// +/// - `read_end == 0` means the alignment never reached the window at all → skip. +/// - `spans` is `true` when the alignment covers the whole requested region +/// (`align_start <= region_start && align_end >= region_end`). In non-partial +/// mode a read that does not fully span the region is skipped; in partial mode +/// any real overlap is accepted and the covered sub-span is returned. fn resolve_cuts( read_cuts: &ReadCuts, config: &BamConfig, - align_start: usize, - align_end: usize, - region_start: usize, - seq_len: usize, + spans: bool, ) -> Option<(usize, usize, usize, usize)> { - if config.partial && align_start > region_start { - let (read_end, ref_end) = if read_cuts.read_start > 0 { - (read_cuts.read_start, read_cuts.ref_start) - } else { - (seq_len, align_end) - }; - Some((0, read_end, align_start, ref_end)) - } else if read_cuts.read_end == 0 { - if config.partial { - Some(( - read_cuts.read_start, - seq_len, - read_cuts.ref_start, - align_end, - )) - } else { - None - } - } else { - Some(( - read_cuts.read_start, - read_cuts.read_end, - read_cuts.ref_start, - read_cuts.ref_end, - )) + if read_cuts.read_end == 0 { + return None; + } + if !config.partial && !spans { + return None; } + Some(( + read_cuts.read_start, + read_cuts.read_end, + read_cuts.ref_start, + read_cuts.ref_end, + )) } /// Returns `false` if a read's actual reference coverage (`ref_start..ref_end`) falls short of @@ -518,22 +501,21 @@ where continue; } - // Clamp the desired window to this read's alignment span so get_read_cuts stays in bounds. - let (eff_start, eff_end) = if lflank == 0 && rflank == 0 { - (region_start, region_end) - } else { - (desired_start.max(align_start), desired_end.min(align_end)) - }; - - let read_cuts: ReadCuts = get_read_cuts(&cigar, align_start, eff_start, eff_end); - let Some((read_start, read_end, ref_start, ref_end)) = resolve_cuts( - &read_cuts, - config, - align_start, - align_end, - region_start, - i_seq.len(), - ) else { + // noodles alignment_end() is the inclusive last aligned position; get_read_cuts + // wants the exclusive one-past-end boundary, in the same frame as region_end. + let align_end_excl = align_end + 1; + + // get_read_cuts clamps its fire-on boundaries to [align_start, align_end_excl] + // internally, so the desired (flank-expanded) window is passed straight through. + let read_cuts: ReadCuts = + get_read_cuts(&cigar, align_start, align_end_excl, desired_start, desired_end); + // Spanning is judged against the requested region (not the flank window): a read + // must cover region_start..region_end to count as full-length; flanks are captured + // opportunistically when the alignment reaches into them. + let spans = align_start <= region_start && align_end_excl >= region_end; + let Some((read_start, read_end, ref_start, ref_end)) = + resolve_cuts(&read_cuts, config, spans) + else { continue; }; if !passes_min_partial_coverage(config, desired_start, desired_end, ref_start, ref_end) { @@ -645,21 +627,15 @@ pub fn get_cram_reads( continue; } - let (eff_start, eff_end) = if lflank == 0 && rflank == 0 { - (region_start, region_end) - } else { - (desired_start.max(align_start), desired_end.min(align_end)) - }; + // noodles alignment_end() is inclusive; get_read_cuts wants one-past-end. + let align_end_excl = align_end + 1; - let read_cuts = get_read_cuts(&cigar, align_start, eff_start, eff_end); - let Some((read_start, read_end, ref_start, ref_end)) = resolve_cuts( - &read_cuts, - config, - align_start, - align_end, - region_start, - i_seq.len(), - ) else { + let read_cuts = + get_read_cuts(&cigar, align_start, align_end_excl, desired_start, desired_end); + let spans = align_start <= region_start && align_end_excl >= region_end; + let Some((read_start, read_end, ref_start, ref_end)) = + resolve_cuts(&read_cuts, config, spans) + else { continue; }; if !passes_min_partial_coverage(config, desired_start, desired_end, ref_start, ref_end) { @@ -802,15 +778,12 @@ where } }; - // Deliberately read_pos_at_ref, not get_read_cuts: get_read_cuts tracks - // two boundaries per call and decides which slot to write into based on - // whether read_start has already fired — a convention that misattributes - // the crossing whenever a boundary coincides exactly with this record's - // own align_start (see read_pos_at_ref's docs). That's not an edge case - // in practice: eff_start clamps to paf_record.target_start (i.e. equals - // align_start exactly) for every record whose window extends past its - // left edge, so this used to silently discard a large share of otherwise - // valid partial overlaps as "invalid coordinates". + // read_pos_at_ref resolves each window edge to a query offset independently, + // which is what the PAF path wants: it handles the strand flip and zero-length + // (fully-inside-a-deletion) cases below by comparing the two offsets directly, + // and eff_start routinely equals paf_record.target_start exactly (any record + // whose window extends past its own left edge), which single-boundary mapping + // handles cleanly. let (Some(read_start), Some(read_end)) = ( read_pos_at_ref(&cigar_ops, paf_record.target_start, eff_start), read_pos_at_ref(&cigar_ops, paf_record.target_start, eff_end), @@ -934,71 +907,52 @@ mod tests { } } - #[test] - fn resolve_cuts_full_span_returns_read_cuts_unchanged() { - let read_cuts = ReadCuts { - read_start: 10, - read_end: 50, - ref_start: 100, - ref_end: 140, - }; - let resolved = resolve_cuts(&read_cuts, &BamConfig::default(), 90, 200, 100, 300); - assert_eq!(resolved, Some((10, 50, 100, 140))); + fn cuts_of(read_start: usize, read_end: usize, ref_start: usize, ref_end: usize) -> ReadCuts { + ReadCuts { + read_start, + read_end, + ref_start, + ref_end, + softclip_lead_start: read_start, + softclip_trail_end: read_end, + } } #[test] - fn resolve_cuts_non_partial_incomplete_overlap_is_skipped() { - // align_start == region_start (not left-partial) but read_end == 0 (never reached - // region_end) and partial is off: the read should be skipped entirely. - let read_cuts = ReadCuts { - read_start: 10, - read_end: 0, - ref_start: 100, - ref_end: 0, - }; - let resolved = resolve_cuts(&read_cuts, &BamConfig::default(), 100, 130, 100, 300); - assert_eq!(resolved, None); + fn resolve_cuts_spanning_returns_read_cuts_unchanged() { + // get_read_cuts already produced correct, clamped offsets — a spanning read + // passes straight through in either mode. + let read_cuts = cuts_of(10, 50, 100, 140); + let resolved = resolve_cuts(&read_cuts, &BamConfig::default(), true); + assert_eq!(resolved, Some((10, 50, 100, 140))); } #[test] - fn resolve_cuts_right_partial_takes_rest_of_read() { - // Alignment starts at region_start but ends before region_end: right-partial. - let read_cuts = ReadCuts { - read_start: 10, - read_end: 0, - ref_start: 100, - ref_end: 0, - }; - let resolved = resolve_cuts(&read_cuts, &partial_config(), 100, 130, 100, 300); - // ref_end falls back to align_end (130) since the region end was never reached. - assert_eq!(resolved, Some((10, 300, 100, 130))); + fn resolve_cuts_no_overlap_is_skipped_even_in_partial_mode() { + // read_end == 0 is the "alignment never reached the window" sentinel: nothing to + // extract, so it's skipped regardless of partial mode. + let read_cuts = cuts_of(10, 0, 100, 0); + assert_eq!(resolve_cuts(&read_cuts, &BamConfig::default(), false), None); + assert_eq!(resolve_cuts(&read_cuts, &partial_config(), false), None); } #[test] - fn resolve_cuts_left_partial_starts_from_zero() { - // Alignment starts after region_start: left-partial. get_read_cuts stores the - // region_end position in read_start/ref_start (see its docs). - let read_cuts = ReadCuts { - read_start: 40, - read_end: 0, - ref_start: 140, - ref_end: 0, - }; - let resolved = resolve_cuts(&read_cuts, &partial_config(), 110, 200, 100, 300); - assert_eq!(resolved, Some((0, 40, 110, 140))); + fn resolve_cuts_non_partial_non_spanning_is_skipped() { + // A real overlap (read_end != 0) that doesn't fully span the region is dropped in + // non-partial mode. + let read_cuts = cuts_of(0, 40, 110, 150); + assert_eq!(resolve_cuts(&read_cuts, &BamConfig::default(), false), None); } #[test] - fn resolve_cuts_left_partial_never_reaches_region_end() { - // Left-partial and the read also ends before region_end is reached at all. - let read_cuts = ReadCuts { - read_start: 0, - read_end: 0, - ref_start: 0, - ref_end: 0, - }; - let resolved = resolve_cuts(&read_cuts, &partial_config(), 110, 150, 100, 300); - assert_eq!(resolved, Some((0, 300, 110, 150))); + fn resolve_cuts_partial_non_spanning_returns_covered_subspan() { + // Same overlap, partial mode: the covered sub-span (already clamped by + // get_read_cuts) is returned as-is. + let read_cuts = cuts_of(0, 40, 110, 150); + assert_eq!( + resolve_cuts(&read_cuts, &partial_config(), false), + Some((0, 40, 110, 150)) + ); } // --- passes_min_partial_coverage --- diff --git a/src/utils.rs b/src/utils.rs index f3797b3..7a12cc8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -37,21 +37,40 @@ pub fn calculate_qscore(qstring: &str) -> f64 { /// /// All positions are 0-based indices into the read sequence array. `ref_start` / /// `ref_end` are the actual reference positions reached during the CIGAR walk and -/// may differ slightly from the requested BED coordinates when a region boundary -/// falls inside a deletion or at the very end of a CIGAR op. +/// may differ from the requested window coordinates when a region boundary falls +/// inside a deletion, at the very end of a CIGAR op, or when the alignment only +/// partially overlaps the window (in which case they are clamped to the span the +/// alignment actually covers — `max(region_start, align_start)` / +/// `min(region_end, align_end)`). +/// +/// This layout is intentionally field-identical to bladerunner's `ReadCuts` so the +/// two can share a single implementation across the crate boundary. #[derive(Debug, Clone)] pub struct ReadCuts { - /// Index of the first read base that corresponds to `region_start` in the - /// reference. Use as the start of a slice into the read sequence: `seq[read_start..read_end]`. + /// Index of the first read base of the extraction. Use as the start of a slice + /// into the read sequence: `seq[read_start..read_end]`. pub read_start: usize, - /// Index one past the last read base that corresponds to `region_end`. - /// A value of `0` is a sentinel meaning the region end was never reached - /// (right-partial overlap or no overlap at all). + /// Index one past the last read base of the extraction. + /// A value of `0` is a sentinel meaning no valid extraction was found — the + /// alignment never reached the window (no overlap at all). Because the start + /// side now uses an explicit `found_start` flag internally (not a `start > 0` + /// sentinel), a real extraction never ends at `0`, so this sentinel is + /// unambiguous. pub read_end: usize, /// The reference position at which `read_start` was set during the CIGAR walk. pub ref_start: usize, /// The reference position at which `read_end` was set during the CIGAR walk. pub ref_end: usize, + /// Read position where a leading soft-clip begins. Equal to `read_start` when no + /// leading extension is available. A value less than `read_start` means the caller + /// can prepend `seq[softclip_lead_start..read_start]` to capture expansion bases + /// that were soft-clipped before the alignment (and thus before the window) start. + pub softclip_lead_start: usize, + /// Read position one-past-the-end of a trailing soft-clip. Equal to `read_end` when + /// no trailing extension is available. A value greater than `read_end` means the + /// caller can append `seq[read_end..softclip_trail_end]` to capture expansion bases + /// that were soft-clipped past the alignment (and thus past the window) end. + pub softclip_trail_end: usize, } /// Walk a CIGAR string to find the read-coordinate slice for a reference region. @@ -68,73 +87,113 @@ pub struct ReadCuts { /// /// ## Coordinate convention (critical) /// -/// `align_start` is **1-based** (as returned by noodles from a BAM record or -/// read from a PAF `target_start` field after adjustment). `region_start` and -/// `region_end` are **0-based** (directly from the BED file). This mixed -/// convention is intentional: it matches the existing behaviour that the tests -/// are written against. Do not normalise both to the same base — it will shift -/// every boundary by one. +/// `align_start` / `align_end` and `region_start` / `region_end` must all be in +/// the **same** coordinate frame. The walk is relative — `ref_pos` starts at +/// `align_start` and advances — so only the *difference* between the boundaries +/// matters for the returned read offsets; the returned `ref_start` / `ref_end` +/// inherit whatever frame the inputs were in. In BAM/CRAM mode both come from +/// noodles' 1-based positions; in the unit tests both use a small self-consistent +/// frame. Do not mix a 1-based `align_start` with a 0-based `region_start` — it +/// shifts every boundary by one. +/// +/// ## Partial overlap and the `align_end` clamp /// -/// ## Partial-overlap sentinel values +/// `region_start` / `region_end` are the *desired* extraction window (already +/// expanded by any flanks/padding the caller wants). The effective boundaries the +/// walk actually fires on are clamped to the span this alignment covers: +/// `ref_start = max(region_start, align_start)` and +/// `ref_end = min(region_end, align_end)`. So a read that only partially overlaps +/// the window still yields a real, in-bounds slice (its `ref_start` / `ref_end` +/// report the covered sub-span) rather than a `read_end == 0` sentinel. The +/// sentinel now only means "no overlap at all". /// -/// When the alignment starts after `region_start` (left-partial or contained -/// read), the `region_start` trigger never fires. If `region_end` is -/// subsequently reached, the position is stored in `read_start` (because -/// `start == 0`), and `read_end` remains `0`. [`get_bam_reads`][crate::reads::get_bam_reads] -/// detects this pattern when `partial` mode is enabled and interprets the -/// fields accordingly. +/// The start boundary uses an explicit `found_start` flag rather than a +/// `start > 0` sentinel, and is recorded at op entry when `ref_pos == ref_start` +/// (e.g. when the alignment begins exactly at the window start, or right after a +/// leading soft-clip). This is what makes `read_start == 0` a legitimate result +/// instead of being conflated with "start not yet found". /// /// # Parameters /// /// - `cigar_ops` — the decoded CIGAR for this alignment. -/// - `align_start` — 1-based reference position where the alignment begins -/// (from the BAM `alignment_start` field or the PAF `target_start` field). -/// - `region_start` — 0-based start of the BED region to extract. -/// - `region_end` — 0-based end of the BED region to extract (exclusive in BED -/// convention, but the CIGAR walk treats it as the last position to fire on). +/// - `align_start` — reference position where the alignment begins. +/// - `align_end` — reference position where the alignment ends (one past the last +/// ref-consuming base), in the same frame as `align_start`. +/// - `region_start` — start of the desired extraction window. +/// - `region_end` — end of the desired extraction window. pub fn get_read_cuts( cigar_ops: &CigarOps, align_start: usize, + align_end: usize, region_start: usize, region_end: usize, ) -> ReadCuts { - let mut start: usize = 0; + let mut start: usize = 0; // read position of the extraction start + let mut found_start: bool = false; // true once start has been determined let mut end: usize = 0; let mut r_start: usize = 0; let mut r_end: usize = 0; let mut pos: usize = 0; let mut ref_pos: usize = align_start; - let ref_start = region_start; - let ref_end = region_end; + // The desired window; kept separately from the clamped boundaries below so the + // soft-clip guards can tell "alignment starts inside the window" apart from + // "window edge". + let pad_ref_start = region_start; + let pad_ref_end = region_end; + + // Clamp the fire-on boundaries to the span this alignment actually covers. + let ref_start = if align_start <= pad_ref_start { + pad_ref_start + } else { + align_start + }; + let ref_end = if align_end >= pad_ref_end { + pad_ref_end + } else { + align_end + }; for op in cigar_ops { match op.kind { Kind::Match | Kind::SequenceMatch | Kind::SequenceMismatch => { + // Record the start now, before advancing, if the alignment begins + // exactly at ref_start (e.g. after leading soft-clips, or when the + // read starts inside the window). Otherwise the inner loop would + // step ref_pos past ref_start on its first increment and miss it. + if !found_start && ref_pos == ref_start { + start = pos; + r_start = ref_pos; + found_start = true; + } if (ref_pos + op.len >= ref_start) || (ref_pos + op.len >= ref_end) { - if (ref_pos + op.len == ref_start) || (ref_pos + op.len == ref_end) { + if !found_start && ref_pos + op.len == ref_start { + // Op ends exactly at ref_start: advance entirely, record start. ref_pos += op.len; pos += op.len; - if start > 0 { - end = pos; - r_end = ref_pos; - break; - } else { - start = pos; - r_start = ref_pos; - } + start = pos; + r_start = ref_pos; + found_start = true; + } else if found_start && ref_pos + op.len == ref_end { + // Op ends exactly at ref_end and start already found: record end, stop. + ref_pos += op.len; + pos += op.len; + end = pos; + r_end = ref_pos; + break; } else { for _ in 0..op.len { ref_pos += 1; pos += 1; if (ref_pos == ref_start) || (ref_pos == ref_end) { - if start > 0 { + if found_start { end = pos; r_end = ref_pos; break; } else { start = pos; r_start = ref_pos; + found_start = true; } } } @@ -146,30 +205,39 @@ pub fn get_read_cuts( } Kind::Insertion | Kind::SoftClip => { pos += op.len; + // After a soft-clip, ref_pos hasn't advanced. If the read begins + // inside the window (ref_start == align_start), the extraction + // starts here — after the clipped bases. + if !found_start && ref_pos == ref_start { + start = pos; + r_start = ref_pos; + found_start = true; + } } Kind::Deletion | Kind::Skip => { if (ref_pos + op.len >= ref_start) || (ref_pos + op.len >= ref_end) { - if (ref_pos + op.len == ref_start) || (ref_pos + op.len == ref_end) { + if !found_start && ref_pos + op.len == ref_start { ref_pos += op.len; - if start > 0 { - end = pos; - r_end = ref_pos; - break; - } else { - start = pos; - r_start = ref_pos; - } + start = pos; + r_start = ref_pos; + found_start = true; + } else if found_start && ref_pos + op.len == ref_end { + ref_pos += op.len; + end = pos; + r_end = ref_pos; + break; } else { for _ in 0..op.len { ref_pos += 1; if (ref_pos == ref_start) || (ref_pos == ref_end) { - if start > 0 { + if found_start { end = pos; r_end = ref_pos; break; } else { start = pos; r_start = ref_pos; + found_start = true; } } } @@ -184,27 +252,49 @@ pub fn get_read_cuts( } } + // A leading soft-clip carries expansion sequence to the left of the alignment + // start (which itself is past the window start). Only meaningful once an + // extraction start was actually found. + let softclip_lead_start = if align_start > pad_ref_start && found_start { + match cigar_ops.first() { + Some(op) if op.kind == Kind::SoftClip => start.saturating_sub(op.len), + _ => start, + } + } else { + start + }; + + // A trailing soft-clip carries expansion sequence to the right of the alignment + // end (which itself is before the window end). Guard on end > 0 (extraction was + // found) and r_end < pad_ref_end (alignment terminated before the window end). + let softclip_trail_end = if end > 0 && r_end < pad_ref_end { + match cigar_ops.last() { + Some(op) if op.kind == Kind::SoftClip => end + op.len, + _ => end, + } + } else { + end + }; + ReadCuts { read_start: start, read_end: end, ref_start: r_start, ref_end: r_end, + softclip_lead_start, + softclip_trail_end, } } /// Walk a CIGAR to find the read-coordinate offset corresponding to a single /// reference position `ref_target`, or `None` if the alignment never reaches it. /// -/// This exists alongside [`get_read_cuts`] rather than reusing it because -/// `get_read_cuts` tracks *two* boundaries at once and decides which slot -/// (`read_start` vs `read_end`) to write into based on whether `read_start` -/// has already been set — a convention that breaks down when only one -/// boundary is needed and it happens to coincide with `align_start` (see -/// `get_read_cuts`'s "Partial-overlap sentinel values" docs): the trigger for -/// that boundary never independently fires, so the *other* boundary's -/// crossing gets misattributed into the wrong field. Callers that only need -/// one reference→read coordinate mapping (e.g. stitching a window's two -/// edges across different PAF records) should use this instead. +/// This exists alongside [`get_read_cuts`] for callers that only need to map a +/// *single* reference position to a read offset, rather than the two-boundary +/// slice `get_read_cuts` produces. The PAF stitching path uses it to resolve a +/// window's two edges independently, each from a *different* chained record's +/// CIGAR — a shape `get_read_cuts` (one CIGAR, both boundaries) does not model. +/// For extracting a slice from a single alignment, prefer [`get_read_cuts`]. /// /// `ref_target <= align_start` returns `Some(0)` (at or before the alignment /// begins). A reference position falling inside a deletion maps to the read @@ -552,13 +642,28 @@ mod tests { assert_eq!(revcomp(&revcomp(seq)), seq); } + /// Reference bases consumed by a CIGAR (M/=/X/D/N), used to derive `align_end` + /// from `align_start` so the test helper mirrors how a real caller computes it. + fn ref_len(ops: &CigarOps) -> usize { + ops.iter() + .filter(|op| { + matches!( + op.kind, + Kind::Match + | Kind::SequenceMatch + | Kind::SequenceMismatch + | Kind::Deletion + | Kind::Skip + ) + }) + .map(|op| op.len) + .sum() + } + fn cuts(cigar: &str, align_start: usize, region_start: usize, region_end: usize) -> ReadCuts { - get_read_cuts( - &cigar.to_cigar_ops().expect("test CIGAR should be valid"), - align_start, - region_start, - region_end, - ) + let ops = cigar.to_cigar_ops().expect("test CIGAR should be valid"); + let align_end = align_start + ref_len(&ops); + get_read_cuts(&ops, align_start, align_end, region_start, region_end) } // --- pure match --- @@ -689,36 +794,142 @@ mod tests { assert_eq!(c.read_end, 0); } - // --- partial overlap (documents semantics used by reads.rs partial mode) --- + // --- partial overlap (align_end clamp returns a real slice, not a sentinel) --- #[test] - fn left_partial_region_end_stored_in_read_start() { - // align_start=5 > region_start=1: the region_start trigger never fires. - // When ref_pos hits region_end=8, start==0 so the position lands in read_start. - // reads.rs partial mode detects this (align_start > region_start) and uses: - // real_start=0, real_end=read_cuts.read_start + fn left_partial_starts_at_read_zero() { + // align_start=5 > region_start=1: the alignment begins inside the window, so + // ref_start clamps to align_start=5 and the extraction starts at read 0. + // ref_end reaches region_end=8. This is the case v0.2.0 got wrong (it filed + // region_end into read_start and left read_end=0). let c = cuts("10M", 5, 1, 8); - assert_eq!(c.read_start, 3); // pos when ref_pos first reached region_end=8 - assert_eq!(c.read_end, 0); + assert_eq!(c.read_start, 0); + assert_eq!(c.read_end, 3); + assert_eq!(c.ref_start, 5); + assert_eq!(c.ref_end, 8); } #[test] - fn contained_read_both_fields_zero() { - // Read (align 10-14) is fully inside region (1-20): neither boundary is hit. - // reads.rs partial mode: real_start=0, real_end=i_seq.len() + fn contained_read_returns_whole_alignment() { + // Read (align 10-15) is fully inside region (1-20): both boundaries clamp to + // the alignment span, so the whole read is extracted. let c = cuts("5M", 10, 1, 20); assert_eq!(c.read_start, 0); - assert_eq!(c.read_end, 0); + assert_eq!(c.read_end, 5); + assert_eq!(c.ref_start, 10); + assert_eq!(c.ref_end, 15); } #[test] - fn right_partial_read_end_zero() { - // Read (align 1-5) spans region_start=3 but ends before region_end=10. - // region_start correctly sets read_start; region_end is never reached so read_end stays 0. - // reads.rs partial mode: real_start=read_cuts.read_start, real_end=i_seq.len() + fn right_partial_ends_at_alignment_end() { + // Read (align 1-6) spans region_start=3 but ends before region_end=10. + // region_start sets read_start; ref_end clamps to align_end=6 so read_end is + // a real offset (v0.2.0 left it at the 0 sentinel). let c = cuts("5M", 1, 3, 10); assert_eq!(c.read_start, 2); + assert_eq!(c.read_end, 5); + assert_eq!(c.ref_start, 3); + assert_eq!(c.ref_end, 6); + } + + // --- boundary-coincidence regression tests (v0.2.0 got these wrong) --- + // + // These are the cases the `start > 0` sentinel mishandled: a boundary that + // coincides exactly with the alignment's own start/end. The `found_start` + // flag + op-entry guard fix them. Expected tuples match bladerunner's + // (correct) differential column. + + #[test] + fn alignment_starts_exactly_at_region_start() { + // 400M with align_start == region_start == 1000. True read_start is 0. + // v0.2.0 never locked in read_start=0 and returned (400, 0, ...). + let c = cuts("400M", 1000, 1000, 1400); + assert_eq!( + (c.read_start, c.read_end, c.ref_start, c.ref_end), + (0, 400, 1000, 1400) + ); + } + + #[test] + fn region_end_coincides_with_alignment_end() { + // One op spans region_start and ends exactly at region_end (== align_end). + // v0.2.0's op-level `== ref_end` shortcut skipped the mid-op region_start + // crossing and returned (1000, 0, ...). + let c = cuts("1000M", 500, 1000, 1500); + assert_eq!( + (c.read_start, c.read_end, c.ref_start, c.ref_end), + (500, 1000, 1000, 1500) + ); + } + + #[test] + fn leading_clip_with_alignment_at_region_start() { + // Leading soft-clip, then the alignment begins exactly at region_start. + // True read_start is 100 (after the clip). v0.2.0 returned (500, 0, ...). + let c = cuts("100S400M", 1000, 1000, 1400); + assert_eq!( + (c.read_start, c.read_end, c.ref_start, c.ref_end), + (100, 500, 1000, 1400) + ); + } + + // --- soft-clip extension fields (ported from bladerunner) --- + + #[test] + fn no_softclip_extension_when_alignment_brackets_region() { + // 200M3000I200M: a large insertion (the expansion) sits inside the region, + // aligned on both sides. No soft-clip extension either way. + let c = cuts("200M3000I200M", 800, 1000, 2000); + assert!(c.read_end > c.read_start); + assert_eq!(c.softclip_lead_start, c.read_start); + assert_eq!(c.softclip_trail_end, c.read_end); + } + + #[test] + fn trailing_softclip_extension_detected() { + // 600M400S: alignment ends (ref 1100) before region_end (2000); the trailing + // 400S carries expansion that couldn't be placed on the reference. + let c = cuts("600M400S", 500, 1000, 2000); + assert!(c.read_end > c.read_start); + assert_eq!(c.softclip_trail_end, c.read_end + 400); + assert_eq!(c.softclip_lead_start, c.read_start); + } + + #[test] + fn leading_softclip_extension_detected() { + // 300S400M: alignment starts (ref 1500) after region_start (1000); the leading + // 300S carries expansion to the left of the alignment start. + let c = cuts("300S400M", 1500, 1000, 2000); + assert!(c.read_end > c.read_start); + assert_eq!(c.softclip_lead_start, c.read_start.saturating_sub(300)); + assert_eq!(c.softclip_trail_end, c.read_end); + } + + #[test] + fn both_softclip_extensions_detected() { + // 200S500M300S: alignment [1200,1700] sits inside region [1000,2000], with + // expansion soft-clipped on both ends. + let c = cuts("200S500M300S", 1200, 1000, 2000); + assert!(c.read_end > c.read_start); + assert_eq!(c.softclip_lead_start, c.read_start.saturating_sub(200)); + assert_eq!(c.softclip_trail_end, c.read_end + 300); + } + + #[test] + fn alignment_before_region_has_no_extension() { + // 100M200S entirely before the region: no extraction, no extension. + let c = cuts("100M200S", 500, 1000, 2000); assert_eq!(c.read_end, 0); + assert_eq!(c.softclip_trail_end, 0); + } + + #[test] + fn trailing_softclip_extension_survives_padding() { + // Same trailing-clip read, but the desired window is padded 5bp on each side + // (as a caller applying flanks would pass). The clip is still detected because + // the alignment ends before the padded window end. + let c = cuts("600M400S", 500, 995, 2005); + assert_eq!(c.softclip_trail_end, c.read_end + 400); } // --- read_pos_at_ref --- diff --git a/tests/integration.rs b/tests/integration.rs index 91e43ed..9d14b1f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -42,7 +42,8 @@ fn to_cigar_ops_trait_accessible_from_crate_root() { #[test] fn read_cuts_type_accessible() { let ops = "10M".to_cigar_ops().unwrap(); - let _cuts: ReadCuts = get_read_cuts(&ops, 1, 3, 7); + // 10M from align_start=1 → align_end (one-past) = 11. + let _cuts: ReadCuts = get_read_cuts(&ops, 1, 11, 3, 7); } // --- PafIndex and PafRecord via lib root --- @@ -128,7 +129,8 @@ fn qscore_below_threshold_detection() { #[test] fn get_read_cuts_insertion_captured() { let ops = "3M5I4M".to_cigar_ops().unwrap(); - let cuts = get_read_cuts(&ops, 1, 3, 7); + // 3M+4M consume 7 ref bases from align_start=1 → align_end (one-past) = 8. + let cuts = get_read_cuts(&ops, 1, 8, 3, 7); assert_eq!(cuts.read_end - cuts.read_start, 9); // 1M + 5I + 3M } From b56cfc6f5b1b5926210bae84266033bb97d2d919 Mon Sep 17 00:00:00 2001 From: Psy-Fer Date: Tue, 21 Jul 2026 14:06:11 +1000 Subject: [PATCH 2/6] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f49536e..013e3bc 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ TODO.md *.bam /docs/book/ *.paf.idx +bedpull_wishlist.md # Benchmark harness: local config (absolute paths, machine-specific) and # generated output/cache are not checked in — only the scripts/templates are. From 657cd38465cfd3f5a7df694f845748fcf5834e3e Mon Sep 17 00:00:00 2001 From: Psy-Fer Date: Tue, 21 Jul 2026 13:57:54 +1000 Subject: [PATCH 3/6] v0.3.0: fix get_read_cuts boundary bug, add soft-clip fields, bump noodles Coordinate-math correctness pass driven by a differential test against bladerunner, aimed at letting bladerunner link bedpull as a library. - Fix boundary-coincidence bug in get_read_cuts: a read whose alignment began/ended exactly at a region boundary was mis-sliced or silently dropped (BAM/CRAM path). Replace the `start > 0` sentinel with an explicit found_start flag + op-entry guard; the op-level `== ref_end` shortcut no longer skips a mid-op ref_start crossing. - get_read_cuts now takes align_end (exclusive) and clamps its fire-on boundaries to the alignment span internally, so partial reads return a real slice instead of a read_end == 0 sentinel. - ReadCuts gains softclip_lead_start / softclip_trail_end, making its layout field-identical to bladerunner's ReadCuts. - Simplify resolve_cuts: spanning is decided by align coverage, not by un-swapping sentinel fields. Real-data BAM/CRAM tests unchanged (40 spanning / 44 partial). - Bump noodles 0.110 -> 0.111 to match bladerunner across the boundary. - Add regression battery for the boundary cases and soft-clip fields; update CHANGELOG and version. --- CHANGELOG.md | 34 ++++ Cargo.lock | 18 +-- Cargo.toml | 4 +- src/main.rs | 25 ++- src/reads.rs | 234 ++++++++++++--------------- src/utils.rs | 371 +++++++++++++++++++++++++++++++++---------- tests/integration.rs | 6 +- 7 files changed, 463 insertions(+), 229 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea7e11..620fda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ This project uses [Semantic Versioning](https://semver.org/). --- +## [0.3.0] — unreleased + +Coordinate-math correctness pass, driven by a differential test against +[bladerunner](https://github.com/Psy-Fer/bladerunner) and aimed at letting bladerunner link +`bedpull` as a library. + +### Fixed + +- **Boundary-coincidence bug in `get_read_cuts`** — a read whose alignment began (or ended) + exactly at a region boundary was mis-sliced or silently dropped. The `start > 0` sentinel + conflated "start not found yet" with "start found at read offset 0". Replaced with an explicit + `found_start` flag plus an op-entry guard, and the op-level `== ref_end` shortcut no longer + skips a mid-op `ref_start` crossing. This affected BAM/CRAM extraction (the PAF path already + routed around it via `read_pos_at_ref`). + +### Changed + +- **`get_read_cuts` signature** is now + `get_read_cuts(cigar_ops, align_start, align_end, region_start, region_end)`. It takes the + alignment end and clamps its fire-on boundaries to the alignment span internally + (`ref_start = max(region_start, align_start)`, `ref_end = min(region_end, align_end)`), so a + partially-overlapping read yields a real slice instead of a `read_end == 0` sentinel. + `region_start`/`region_end` are the desired (flank-expanded) window; the caller no longer + pre-clamps. **`align_end` is exclusive (one past the last reference base)** — callers using + noodles' inclusive `alignment_end()` must add 1. +- **`ReadCuts` gains `softclip_lead_start` and `softclip_trail_end`** (read offsets of the + leading/trailing soft-clip runs; equal to `read_start`/`read_end` when no extension is + available). Layout is now field-identical to bladerunner's `ReadCuts`. +- **noodles bumped `0.110` → `0.111`** to match bladerunner across the crate boundary. + +### Added + +- Regression battery for the boundary-coincidence cases and the soft-clip extension fields. + ## [0.2.0] — 2026-07-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index e860f70..b0c5f38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,7 +66,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "bedpull" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "clap", @@ -538,9 +538,9 @@ dependencies = [ [[package]] name = "noodles" -version = "0.110.0" +version = "0.111.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34e7ed524a472dbd0dc69cc7b63408f37552e48460028af44a3024f2eb77f036" +checksum = "78906b00d2b2d144c920567724ab0dc68ef8da7fc258ef18da86bbbec572000e" dependencies = [ "noodles-bam", "noodles-bed", @@ -554,9 +554,9 @@ dependencies = [ [[package]] name = "noodles-bam" -version = "0.89.0" +version = "0.90.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c1872afd5963f751ae78ef04ab3fb050aefc615c05c04cda7c54120eedf9b0" +checksum = "4d319ea3e4414172455eec82f0283ae3fc6a5a8e9b23bdc16ee426986a615094" dependencies = [ "bstr", "indexmap", @@ -604,9 +604,9 @@ dependencies = [ [[package]] name = "noodles-cram" -version = "0.93.0" +version = "0.94.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae197edf3fbf530f6cbd4cf22dc36dba94b1a14d6ed1a925bd17e0eaecc40e8" +checksum = "267b2934c706c2372af2eefde6c936cc5fbf26a899f5844cc48d62ea51cbd933" dependencies = [ "bitflags", "bstr", @@ -637,9 +637,9 @@ dependencies = [ [[package]] name = "noodles-fasta" -version = "0.61.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0e6a772d7a5cb566196d1a1806c4471ad63bc6ffad5b2d549e9602708a9aaab" +checksum = "c36aecf3899ba8ca698bddee3c7264d6e70afca6620524ece230d2c34b93021b" dependencies = [ "bstr", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 93d7d28..e892c9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bedpull" -version = "0.2.0" +version = "0.3.0" edition = "2024" authors = ["James M. Ferguson "] repository = "https://github.com/Psy-Fer/bedpull" @@ -22,7 +22,7 @@ tempfile = "3" [dependencies] clap = { version = "4", features = ["derive", "cargo", "wrap_help"] } -noodles = { version = "0.110.0", features = ["core", "bgzf", "bam", "sam", "bed", "fasta", "fastq", "cram"] } +noodles = { version = "0.111.0", features = ["core", "bgzf", "bam", "sam", "bed", "fasta", "fastq", "cram"] } itertools = "0.11" anyhow = "1.0" csv = "1.3" diff --git a/src/main.rs b/src/main.rs index 58081ef..7fe5e17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -854,10 +854,33 @@ mod tests { #[test] fn get_read_cuts_rfc1_captures_insertion() { + use noodles::sam::alignment::record::cigar::op::Kind; let r = read_paf_record_at_offset(PAF_PATH, 0).unwrap(); let cigar_str = r.cigar.as_deref().expect("no CIGAR"); let ops = cigar_str.to_cigar_ops().expect("valid CIGAR from PAF file"); - let cuts = get_read_cuts(&ops, RFC1_TARGET_START, RFC1_REGION_START, RFC1_REGION_END); + // align_end = target_start + reference bases consumed (M/=/X/D/N). + let ref_len: usize = ops + .iter() + .filter(|op| { + matches!( + op.kind, + Kind::Match + | Kind::SequenceMatch + | Kind::SequenceMismatch + | Kind::Deletion + | Kind::Skip + ) + }) + .map(|op| op.len) + .sum(); + let align_end = RFC1_TARGET_START + ref_len; + let cuts = get_read_cuts( + &ops, + RFC1_TARGET_START, + align_end, + RFC1_REGION_START, + RFC1_REGION_END, + ); let extracted_len = cuts.read_end - cuts.read_start; assert_eq!( extracted_len, RFC1_EXPECTED_BP, diff --git a/src/reads.rs b/src/reads.rs index ef7ccc1..2907176 100644 --- a/src/reads.rs +++ b/src/reads.rs @@ -251,14 +251,11 @@ where return Ok(None); }; - // Deliberately not get_read_cuts: it tracks two boundaries per call and - // decides which slot to write into based on whether the first has - // already fired, a convention that breaks when a boundary coincides - // exactly with a record's own align_start (see read_pos_at_ref's docs). - // That's not a hypothetical here — desired_start legitimately does equal - // first.target_start whenever the window's left edge lines up exactly - // with the first chain member's own start (same for desired_end and - // last.target_end), so each boundary is resolved independently instead. + // Deliberately not get_read_cuts: the two window edges live on *different* + // chained records here (desired_start on the first record's CIGAR, + // desired_end on the last's), so each is resolved independently with + // read_pos_at_ref. get_read_cuts models a single alignment's two boundaries + // and doesn't fit a boundary-per-record split. let (Some(read_pos_first), Some(read_pos_last)) = ( read_pos_at_ref(&first_ops, first.target_start, desired_start), read_pos_at_ref(&last_ops, last.target_start, desired_end), @@ -336,50 +333,36 @@ where } /// Resolve the final `(read_start, read_end, ref_start, ref_end)` for a read from -/// its raw [`ReadCuts`], applying partial-overlap fallback rules. +/// its raw [`ReadCuts`]. /// -/// `get_read_cuts` fires on `ref_pos == region_start` and `ref_pos == region_end`. When -/// the alignment starts after `region_start`, the `region_start` trigger never fires; -/// `read_cuts.ref_start`/`read_cuts.read_start` end up holding the `region_end` position -/// instead (see [`get_read_cuts`][crate::utils::get_read_cuts] docs), and `read_end`/`ref_end` -/// stay `0`. This function undoes that mislabelling so callers get the reference span -/// actually covered by the returned slice, not the raw (and sometimes swapped) fields. -/// Returns `None` when the read doesn't satisfy the region and `config.partial` is `false` -/// (the read should be skipped). +/// [`get_read_cuts`][crate::utils::get_read_cuts] now clamps the fire-on boundaries +/// to the alignment span itself, so `read_start`/`read_end` are already correct read +/// offsets and `ref_start`/`ref_end` already report the covered sub-span — no +/// sentinel un-swapping is needed. This function is left as the one place the +/// spanning/partial *policy* is applied: +/// +/// - `read_end == 0` means the alignment never reached the window at all → skip. +/// - `spans` is `true` when the alignment covers the whole requested region +/// (`align_start <= region_start && align_end >= region_end`). In non-partial +/// mode a read that does not fully span the region is skipped; in partial mode +/// any real overlap is accepted and the covered sub-span is returned. fn resolve_cuts( read_cuts: &ReadCuts, config: &BamConfig, - align_start: usize, - align_end: usize, - region_start: usize, - seq_len: usize, + spans: bool, ) -> Option<(usize, usize, usize, usize)> { - if config.partial && align_start > region_start { - let (read_end, ref_end) = if read_cuts.read_start > 0 { - (read_cuts.read_start, read_cuts.ref_start) - } else { - (seq_len, align_end) - }; - Some((0, read_end, align_start, ref_end)) - } else if read_cuts.read_end == 0 { - if config.partial { - Some(( - read_cuts.read_start, - seq_len, - read_cuts.ref_start, - align_end, - )) - } else { - None - } - } else { - Some(( - read_cuts.read_start, - read_cuts.read_end, - read_cuts.ref_start, - read_cuts.ref_end, - )) + if read_cuts.read_end == 0 { + return None; } + if !config.partial && !spans { + return None; + } + Some(( + read_cuts.read_start, + read_cuts.read_end, + read_cuts.ref_start, + read_cuts.ref_end, + )) } /// Returns `false` if a read's actual reference coverage (`ref_start..ref_end`) falls short of @@ -518,22 +501,26 @@ where continue; } - // Clamp the desired window to this read's alignment span so get_read_cuts stays in bounds. - let (eff_start, eff_end) = if lflank == 0 && rflank == 0 { - (region_start, region_end) - } else { - (desired_start.max(align_start), desired_end.min(align_end)) - }; + // noodles alignment_end() is the inclusive last aligned position; get_read_cuts + // wants the exclusive one-past-end boundary, in the same frame as region_end. + let align_end_excl = align_end + 1; - let read_cuts: ReadCuts = get_read_cuts(&cigar, align_start, eff_start, eff_end); - let Some((read_start, read_end, ref_start, ref_end)) = resolve_cuts( - &read_cuts, - config, + // get_read_cuts clamps its fire-on boundaries to [align_start, align_end_excl] + // internally, so the desired (flank-expanded) window is passed straight through. + let read_cuts: ReadCuts = get_read_cuts( + &cigar, align_start, - align_end, - region_start, - i_seq.len(), - ) else { + align_end_excl, + desired_start, + desired_end, + ); + // Spanning is judged against the requested region (not the flank window): a read + // must cover region_start..region_end to count as full-length; flanks are captured + // opportunistically when the alignment reaches into them. + let spans = align_start <= region_start && align_end_excl >= region_end; + let Some((read_start, read_end, ref_start, ref_end)) = + resolve_cuts(&read_cuts, config, spans) + else { continue; }; if !passes_min_partial_coverage(config, desired_start, desired_end, ref_start, ref_end) { @@ -645,21 +632,20 @@ pub fn get_cram_reads( continue; } - let (eff_start, eff_end) = if lflank == 0 && rflank == 0 { - (region_start, region_end) - } else { - (desired_start.max(align_start), desired_end.min(align_end)) - }; + // noodles alignment_end() is inclusive; get_read_cuts wants one-past-end. + let align_end_excl = align_end + 1; - let read_cuts = get_read_cuts(&cigar, align_start, eff_start, eff_end); - let Some((read_start, read_end, ref_start, ref_end)) = resolve_cuts( - &read_cuts, - config, + let read_cuts = get_read_cuts( + &cigar, align_start, - align_end, - region_start, - i_seq.len(), - ) else { + align_end_excl, + desired_start, + desired_end, + ); + let spans = align_start <= region_start && align_end_excl >= region_end; + let Some((read_start, read_end, ref_start, ref_end)) = + resolve_cuts(&read_cuts, config, spans) + else { continue; }; if !passes_min_partial_coverage(config, desired_start, desired_end, ref_start, ref_end) { @@ -802,15 +788,12 @@ where } }; - // Deliberately read_pos_at_ref, not get_read_cuts: get_read_cuts tracks - // two boundaries per call and decides which slot to write into based on - // whether read_start has already fired — a convention that misattributes - // the crossing whenever a boundary coincides exactly with this record's - // own align_start (see read_pos_at_ref's docs). That's not an edge case - // in practice: eff_start clamps to paf_record.target_start (i.e. equals - // align_start exactly) for every record whose window extends past its - // left edge, so this used to silently discard a large share of otherwise - // valid partial overlaps as "invalid coordinates". + // read_pos_at_ref resolves each window edge to a query offset independently, + // which is what the PAF path wants: it handles the strand flip and zero-length + // (fully-inside-a-deletion) cases below by comparing the two offsets directly, + // and eff_start routinely equals paf_record.target_start exactly (any record + // whose window extends past its own left edge), which single-boundary mapping + // handles cleanly. let (Some(read_start), Some(read_end)) = ( read_pos_at_ref(&cigar_ops, paf_record.target_start, eff_start), read_pos_at_ref(&cigar_ops, paf_record.target_start, eff_end), @@ -934,71 +917,52 @@ mod tests { } } - #[test] - fn resolve_cuts_full_span_returns_read_cuts_unchanged() { - let read_cuts = ReadCuts { - read_start: 10, - read_end: 50, - ref_start: 100, - ref_end: 140, - }; - let resolved = resolve_cuts(&read_cuts, &BamConfig::default(), 90, 200, 100, 300); - assert_eq!(resolved, Some((10, 50, 100, 140))); + fn cuts_of(read_start: usize, read_end: usize, ref_start: usize, ref_end: usize) -> ReadCuts { + ReadCuts { + read_start, + read_end, + ref_start, + ref_end, + softclip_lead_start: read_start, + softclip_trail_end: read_end, + } } #[test] - fn resolve_cuts_non_partial_incomplete_overlap_is_skipped() { - // align_start == region_start (not left-partial) but read_end == 0 (never reached - // region_end) and partial is off: the read should be skipped entirely. - let read_cuts = ReadCuts { - read_start: 10, - read_end: 0, - ref_start: 100, - ref_end: 0, - }; - let resolved = resolve_cuts(&read_cuts, &BamConfig::default(), 100, 130, 100, 300); - assert_eq!(resolved, None); + fn resolve_cuts_spanning_returns_read_cuts_unchanged() { + // get_read_cuts already produced correct, clamped offsets — a spanning read + // passes straight through in either mode. + let read_cuts = cuts_of(10, 50, 100, 140); + let resolved = resolve_cuts(&read_cuts, &BamConfig::default(), true); + assert_eq!(resolved, Some((10, 50, 100, 140))); } #[test] - fn resolve_cuts_right_partial_takes_rest_of_read() { - // Alignment starts at region_start but ends before region_end: right-partial. - let read_cuts = ReadCuts { - read_start: 10, - read_end: 0, - ref_start: 100, - ref_end: 0, - }; - let resolved = resolve_cuts(&read_cuts, &partial_config(), 100, 130, 100, 300); - // ref_end falls back to align_end (130) since the region end was never reached. - assert_eq!(resolved, Some((10, 300, 100, 130))); + fn resolve_cuts_no_overlap_is_skipped_even_in_partial_mode() { + // read_end == 0 is the "alignment never reached the window" sentinel: nothing to + // extract, so it's skipped regardless of partial mode. + let read_cuts = cuts_of(10, 0, 100, 0); + assert_eq!(resolve_cuts(&read_cuts, &BamConfig::default(), false), None); + assert_eq!(resolve_cuts(&read_cuts, &partial_config(), false), None); } #[test] - fn resolve_cuts_left_partial_starts_from_zero() { - // Alignment starts after region_start: left-partial. get_read_cuts stores the - // region_end position in read_start/ref_start (see its docs). - let read_cuts = ReadCuts { - read_start: 40, - read_end: 0, - ref_start: 140, - ref_end: 0, - }; - let resolved = resolve_cuts(&read_cuts, &partial_config(), 110, 200, 100, 300); - assert_eq!(resolved, Some((0, 40, 110, 140))); + fn resolve_cuts_non_partial_non_spanning_is_skipped() { + // A real overlap (read_end != 0) that doesn't fully span the region is dropped in + // non-partial mode. + let read_cuts = cuts_of(0, 40, 110, 150); + assert_eq!(resolve_cuts(&read_cuts, &BamConfig::default(), false), None); } #[test] - fn resolve_cuts_left_partial_never_reaches_region_end() { - // Left-partial and the read also ends before region_end is reached at all. - let read_cuts = ReadCuts { - read_start: 0, - read_end: 0, - ref_start: 0, - ref_end: 0, - }; - let resolved = resolve_cuts(&read_cuts, &partial_config(), 110, 150, 100, 300); - assert_eq!(resolved, Some((0, 300, 110, 150))); + fn resolve_cuts_partial_non_spanning_returns_covered_subspan() { + // Same overlap, partial mode: the covered sub-span (already clamped by + // get_read_cuts) is returned as-is. + let read_cuts = cuts_of(0, 40, 110, 150); + assert_eq!( + resolve_cuts(&read_cuts, &partial_config(), false), + Some((0, 40, 110, 150)) + ); } // --- passes_min_partial_coverage --- diff --git a/src/utils.rs b/src/utils.rs index f3797b3..7a12cc8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -37,21 +37,40 @@ pub fn calculate_qscore(qstring: &str) -> f64 { /// /// All positions are 0-based indices into the read sequence array. `ref_start` / /// `ref_end` are the actual reference positions reached during the CIGAR walk and -/// may differ slightly from the requested BED coordinates when a region boundary -/// falls inside a deletion or at the very end of a CIGAR op. +/// may differ from the requested window coordinates when a region boundary falls +/// inside a deletion, at the very end of a CIGAR op, or when the alignment only +/// partially overlaps the window (in which case they are clamped to the span the +/// alignment actually covers — `max(region_start, align_start)` / +/// `min(region_end, align_end)`). +/// +/// This layout is intentionally field-identical to bladerunner's `ReadCuts` so the +/// two can share a single implementation across the crate boundary. #[derive(Debug, Clone)] pub struct ReadCuts { - /// Index of the first read base that corresponds to `region_start` in the - /// reference. Use as the start of a slice into the read sequence: `seq[read_start..read_end]`. + /// Index of the first read base of the extraction. Use as the start of a slice + /// into the read sequence: `seq[read_start..read_end]`. pub read_start: usize, - /// Index one past the last read base that corresponds to `region_end`. - /// A value of `0` is a sentinel meaning the region end was never reached - /// (right-partial overlap or no overlap at all). + /// Index one past the last read base of the extraction. + /// A value of `0` is a sentinel meaning no valid extraction was found — the + /// alignment never reached the window (no overlap at all). Because the start + /// side now uses an explicit `found_start` flag internally (not a `start > 0` + /// sentinel), a real extraction never ends at `0`, so this sentinel is + /// unambiguous. pub read_end: usize, /// The reference position at which `read_start` was set during the CIGAR walk. pub ref_start: usize, /// The reference position at which `read_end` was set during the CIGAR walk. pub ref_end: usize, + /// Read position where a leading soft-clip begins. Equal to `read_start` when no + /// leading extension is available. A value less than `read_start` means the caller + /// can prepend `seq[softclip_lead_start..read_start]` to capture expansion bases + /// that were soft-clipped before the alignment (and thus before the window) start. + pub softclip_lead_start: usize, + /// Read position one-past-the-end of a trailing soft-clip. Equal to `read_end` when + /// no trailing extension is available. A value greater than `read_end` means the + /// caller can append `seq[read_end..softclip_trail_end]` to capture expansion bases + /// that were soft-clipped past the alignment (and thus past the window) end. + pub softclip_trail_end: usize, } /// Walk a CIGAR string to find the read-coordinate slice for a reference region. @@ -68,73 +87,113 @@ pub struct ReadCuts { /// /// ## Coordinate convention (critical) /// -/// `align_start` is **1-based** (as returned by noodles from a BAM record or -/// read from a PAF `target_start` field after adjustment). `region_start` and -/// `region_end` are **0-based** (directly from the BED file). This mixed -/// convention is intentional: it matches the existing behaviour that the tests -/// are written against. Do not normalise both to the same base — it will shift -/// every boundary by one. +/// `align_start` / `align_end` and `region_start` / `region_end` must all be in +/// the **same** coordinate frame. The walk is relative — `ref_pos` starts at +/// `align_start` and advances — so only the *difference* between the boundaries +/// matters for the returned read offsets; the returned `ref_start` / `ref_end` +/// inherit whatever frame the inputs were in. In BAM/CRAM mode both come from +/// noodles' 1-based positions; in the unit tests both use a small self-consistent +/// frame. Do not mix a 1-based `align_start` with a 0-based `region_start` — it +/// shifts every boundary by one. +/// +/// ## Partial overlap and the `align_end` clamp /// -/// ## Partial-overlap sentinel values +/// `region_start` / `region_end` are the *desired* extraction window (already +/// expanded by any flanks/padding the caller wants). The effective boundaries the +/// walk actually fires on are clamped to the span this alignment covers: +/// `ref_start = max(region_start, align_start)` and +/// `ref_end = min(region_end, align_end)`. So a read that only partially overlaps +/// the window still yields a real, in-bounds slice (its `ref_start` / `ref_end` +/// report the covered sub-span) rather than a `read_end == 0` sentinel. The +/// sentinel now only means "no overlap at all". /// -/// When the alignment starts after `region_start` (left-partial or contained -/// read), the `region_start` trigger never fires. If `region_end` is -/// subsequently reached, the position is stored in `read_start` (because -/// `start == 0`), and `read_end` remains `0`. [`get_bam_reads`][crate::reads::get_bam_reads] -/// detects this pattern when `partial` mode is enabled and interprets the -/// fields accordingly. +/// The start boundary uses an explicit `found_start` flag rather than a +/// `start > 0` sentinel, and is recorded at op entry when `ref_pos == ref_start` +/// (e.g. when the alignment begins exactly at the window start, or right after a +/// leading soft-clip). This is what makes `read_start == 0` a legitimate result +/// instead of being conflated with "start not yet found". /// /// # Parameters /// /// - `cigar_ops` — the decoded CIGAR for this alignment. -/// - `align_start` — 1-based reference position where the alignment begins -/// (from the BAM `alignment_start` field or the PAF `target_start` field). -/// - `region_start` — 0-based start of the BED region to extract. -/// - `region_end` — 0-based end of the BED region to extract (exclusive in BED -/// convention, but the CIGAR walk treats it as the last position to fire on). +/// - `align_start` — reference position where the alignment begins. +/// - `align_end` — reference position where the alignment ends (one past the last +/// ref-consuming base), in the same frame as `align_start`. +/// - `region_start` — start of the desired extraction window. +/// - `region_end` — end of the desired extraction window. pub fn get_read_cuts( cigar_ops: &CigarOps, align_start: usize, + align_end: usize, region_start: usize, region_end: usize, ) -> ReadCuts { - let mut start: usize = 0; + let mut start: usize = 0; // read position of the extraction start + let mut found_start: bool = false; // true once start has been determined let mut end: usize = 0; let mut r_start: usize = 0; let mut r_end: usize = 0; let mut pos: usize = 0; let mut ref_pos: usize = align_start; - let ref_start = region_start; - let ref_end = region_end; + // The desired window; kept separately from the clamped boundaries below so the + // soft-clip guards can tell "alignment starts inside the window" apart from + // "window edge". + let pad_ref_start = region_start; + let pad_ref_end = region_end; + + // Clamp the fire-on boundaries to the span this alignment actually covers. + let ref_start = if align_start <= pad_ref_start { + pad_ref_start + } else { + align_start + }; + let ref_end = if align_end >= pad_ref_end { + pad_ref_end + } else { + align_end + }; for op in cigar_ops { match op.kind { Kind::Match | Kind::SequenceMatch | Kind::SequenceMismatch => { + // Record the start now, before advancing, if the alignment begins + // exactly at ref_start (e.g. after leading soft-clips, or when the + // read starts inside the window). Otherwise the inner loop would + // step ref_pos past ref_start on its first increment and miss it. + if !found_start && ref_pos == ref_start { + start = pos; + r_start = ref_pos; + found_start = true; + } if (ref_pos + op.len >= ref_start) || (ref_pos + op.len >= ref_end) { - if (ref_pos + op.len == ref_start) || (ref_pos + op.len == ref_end) { + if !found_start && ref_pos + op.len == ref_start { + // Op ends exactly at ref_start: advance entirely, record start. ref_pos += op.len; pos += op.len; - if start > 0 { - end = pos; - r_end = ref_pos; - break; - } else { - start = pos; - r_start = ref_pos; - } + start = pos; + r_start = ref_pos; + found_start = true; + } else if found_start && ref_pos + op.len == ref_end { + // Op ends exactly at ref_end and start already found: record end, stop. + ref_pos += op.len; + pos += op.len; + end = pos; + r_end = ref_pos; + break; } else { for _ in 0..op.len { ref_pos += 1; pos += 1; if (ref_pos == ref_start) || (ref_pos == ref_end) { - if start > 0 { + if found_start { end = pos; r_end = ref_pos; break; } else { start = pos; r_start = ref_pos; + found_start = true; } } } @@ -146,30 +205,39 @@ pub fn get_read_cuts( } Kind::Insertion | Kind::SoftClip => { pos += op.len; + // After a soft-clip, ref_pos hasn't advanced. If the read begins + // inside the window (ref_start == align_start), the extraction + // starts here — after the clipped bases. + if !found_start && ref_pos == ref_start { + start = pos; + r_start = ref_pos; + found_start = true; + } } Kind::Deletion | Kind::Skip => { if (ref_pos + op.len >= ref_start) || (ref_pos + op.len >= ref_end) { - if (ref_pos + op.len == ref_start) || (ref_pos + op.len == ref_end) { + if !found_start && ref_pos + op.len == ref_start { ref_pos += op.len; - if start > 0 { - end = pos; - r_end = ref_pos; - break; - } else { - start = pos; - r_start = ref_pos; - } + start = pos; + r_start = ref_pos; + found_start = true; + } else if found_start && ref_pos + op.len == ref_end { + ref_pos += op.len; + end = pos; + r_end = ref_pos; + break; } else { for _ in 0..op.len { ref_pos += 1; if (ref_pos == ref_start) || (ref_pos == ref_end) { - if start > 0 { + if found_start { end = pos; r_end = ref_pos; break; } else { start = pos; r_start = ref_pos; + found_start = true; } } } @@ -184,27 +252,49 @@ pub fn get_read_cuts( } } + // A leading soft-clip carries expansion sequence to the left of the alignment + // start (which itself is past the window start). Only meaningful once an + // extraction start was actually found. + let softclip_lead_start = if align_start > pad_ref_start && found_start { + match cigar_ops.first() { + Some(op) if op.kind == Kind::SoftClip => start.saturating_sub(op.len), + _ => start, + } + } else { + start + }; + + // A trailing soft-clip carries expansion sequence to the right of the alignment + // end (which itself is before the window end). Guard on end > 0 (extraction was + // found) and r_end < pad_ref_end (alignment terminated before the window end). + let softclip_trail_end = if end > 0 && r_end < pad_ref_end { + match cigar_ops.last() { + Some(op) if op.kind == Kind::SoftClip => end + op.len, + _ => end, + } + } else { + end + }; + ReadCuts { read_start: start, read_end: end, ref_start: r_start, ref_end: r_end, + softclip_lead_start, + softclip_trail_end, } } /// Walk a CIGAR to find the read-coordinate offset corresponding to a single /// reference position `ref_target`, or `None` if the alignment never reaches it. /// -/// This exists alongside [`get_read_cuts`] rather than reusing it because -/// `get_read_cuts` tracks *two* boundaries at once and decides which slot -/// (`read_start` vs `read_end`) to write into based on whether `read_start` -/// has already been set — a convention that breaks down when only one -/// boundary is needed and it happens to coincide with `align_start` (see -/// `get_read_cuts`'s "Partial-overlap sentinel values" docs): the trigger for -/// that boundary never independently fires, so the *other* boundary's -/// crossing gets misattributed into the wrong field. Callers that only need -/// one reference→read coordinate mapping (e.g. stitching a window's two -/// edges across different PAF records) should use this instead. +/// This exists alongside [`get_read_cuts`] for callers that only need to map a +/// *single* reference position to a read offset, rather than the two-boundary +/// slice `get_read_cuts` produces. The PAF stitching path uses it to resolve a +/// window's two edges independently, each from a *different* chained record's +/// CIGAR — a shape `get_read_cuts` (one CIGAR, both boundaries) does not model. +/// For extracting a slice from a single alignment, prefer [`get_read_cuts`]. /// /// `ref_target <= align_start` returns `Some(0)` (at or before the alignment /// begins). A reference position falling inside a deletion maps to the read @@ -552,13 +642,28 @@ mod tests { assert_eq!(revcomp(&revcomp(seq)), seq); } + /// Reference bases consumed by a CIGAR (M/=/X/D/N), used to derive `align_end` + /// from `align_start` so the test helper mirrors how a real caller computes it. + fn ref_len(ops: &CigarOps) -> usize { + ops.iter() + .filter(|op| { + matches!( + op.kind, + Kind::Match + | Kind::SequenceMatch + | Kind::SequenceMismatch + | Kind::Deletion + | Kind::Skip + ) + }) + .map(|op| op.len) + .sum() + } + fn cuts(cigar: &str, align_start: usize, region_start: usize, region_end: usize) -> ReadCuts { - get_read_cuts( - &cigar.to_cigar_ops().expect("test CIGAR should be valid"), - align_start, - region_start, - region_end, - ) + let ops = cigar.to_cigar_ops().expect("test CIGAR should be valid"); + let align_end = align_start + ref_len(&ops); + get_read_cuts(&ops, align_start, align_end, region_start, region_end) } // --- pure match --- @@ -689,36 +794,142 @@ mod tests { assert_eq!(c.read_end, 0); } - // --- partial overlap (documents semantics used by reads.rs partial mode) --- + // --- partial overlap (align_end clamp returns a real slice, not a sentinel) --- #[test] - fn left_partial_region_end_stored_in_read_start() { - // align_start=5 > region_start=1: the region_start trigger never fires. - // When ref_pos hits region_end=8, start==0 so the position lands in read_start. - // reads.rs partial mode detects this (align_start > region_start) and uses: - // real_start=0, real_end=read_cuts.read_start + fn left_partial_starts_at_read_zero() { + // align_start=5 > region_start=1: the alignment begins inside the window, so + // ref_start clamps to align_start=5 and the extraction starts at read 0. + // ref_end reaches region_end=8. This is the case v0.2.0 got wrong (it filed + // region_end into read_start and left read_end=0). let c = cuts("10M", 5, 1, 8); - assert_eq!(c.read_start, 3); // pos when ref_pos first reached region_end=8 - assert_eq!(c.read_end, 0); + assert_eq!(c.read_start, 0); + assert_eq!(c.read_end, 3); + assert_eq!(c.ref_start, 5); + assert_eq!(c.ref_end, 8); } #[test] - fn contained_read_both_fields_zero() { - // Read (align 10-14) is fully inside region (1-20): neither boundary is hit. - // reads.rs partial mode: real_start=0, real_end=i_seq.len() + fn contained_read_returns_whole_alignment() { + // Read (align 10-15) is fully inside region (1-20): both boundaries clamp to + // the alignment span, so the whole read is extracted. let c = cuts("5M", 10, 1, 20); assert_eq!(c.read_start, 0); - assert_eq!(c.read_end, 0); + assert_eq!(c.read_end, 5); + assert_eq!(c.ref_start, 10); + assert_eq!(c.ref_end, 15); } #[test] - fn right_partial_read_end_zero() { - // Read (align 1-5) spans region_start=3 but ends before region_end=10. - // region_start correctly sets read_start; region_end is never reached so read_end stays 0. - // reads.rs partial mode: real_start=read_cuts.read_start, real_end=i_seq.len() + fn right_partial_ends_at_alignment_end() { + // Read (align 1-6) spans region_start=3 but ends before region_end=10. + // region_start sets read_start; ref_end clamps to align_end=6 so read_end is + // a real offset (v0.2.0 left it at the 0 sentinel). let c = cuts("5M", 1, 3, 10); assert_eq!(c.read_start, 2); + assert_eq!(c.read_end, 5); + assert_eq!(c.ref_start, 3); + assert_eq!(c.ref_end, 6); + } + + // --- boundary-coincidence regression tests (v0.2.0 got these wrong) --- + // + // These are the cases the `start > 0` sentinel mishandled: a boundary that + // coincides exactly with the alignment's own start/end. The `found_start` + // flag + op-entry guard fix them. Expected tuples match bladerunner's + // (correct) differential column. + + #[test] + fn alignment_starts_exactly_at_region_start() { + // 400M with align_start == region_start == 1000. True read_start is 0. + // v0.2.0 never locked in read_start=0 and returned (400, 0, ...). + let c = cuts("400M", 1000, 1000, 1400); + assert_eq!( + (c.read_start, c.read_end, c.ref_start, c.ref_end), + (0, 400, 1000, 1400) + ); + } + + #[test] + fn region_end_coincides_with_alignment_end() { + // One op spans region_start and ends exactly at region_end (== align_end). + // v0.2.0's op-level `== ref_end` shortcut skipped the mid-op region_start + // crossing and returned (1000, 0, ...). + let c = cuts("1000M", 500, 1000, 1500); + assert_eq!( + (c.read_start, c.read_end, c.ref_start, c.ref_end), + (500, 1000, 1000, 1500) + ); + } + + #[test] + fn leading_clip_with_alignment_at_region_start() { + // Leading soft-clip, then the alignment begins exactly at region_start. + // True read_start is 100 (after the clip). v0.2.0 returned (500, 0, ...). + let c = cuts("100S400M", 1000, 1000, 1400); + assert_eq!( + (c.read_start, c.read_end, c.ref_start, c.ref_end), + (100, 500, 1000, 1400) + ); + } + + // --- soft-clip extension fields (ported from bladerunner) --- + + #[test] + fn no_softclip_extension_when_alignment_brackets_region() { + // 200M3000I200M: a large insertion (the expansion) sits inside the region, + // aligned on both sides. No soft-clip extension either way. + let c = cuts("200M3000I200M", 800, 1000, 2000); + assert!(c.read_end > c.read_start); + assert_eq!(c.softclip_lead_start, c.read_start); + assert_eq!(c.softclip_trail_end, c.read_end); + } + + #[test] + fn trailing_softclip_extension_detected() { + // 600M400S: alignment ends (ref 1100) before region_end (2000); the trailing + // 400S carries expansion that couldn't be placed on the reference. + let c = cuts("600M400S", 500, 1000, 2000); + assert!(c.read_end > c.read_start); + assert_eq!(c.softclip_trail_end, c.read_end + 400); + assert_eq!(c.softclip_lead_start, c.read_start); + } + + #[test] + fn leading_softclip_extension_detected() { + // 300S400M: alignment starts (ref 1500) after region_start (1000); the leading + // 300S carries expansion to the left of the alignment start. + let c = cuts("300S400M", 1500, 1000, 2000); + assert!(c.read_end > c.read_start); + assert_eq!(c.softclip_lead_start, c.read_start.saturating_sub(300)); + assert_eq!(c.softclip_trail_end, c.read_end); + } + + #[test] + fn both_softclip_extensions_detected() { + // 200S500M300S: alignment [1200,1700] sits inside region [1000,2000], with + // expansion soft-clipped on both ends. + let c = cuts("200S500M300S", 1200, 1000, 2000); + assert!(c.read_end > c.read_start); + assert_eq!(c.softclip_lead_start, c.read_start.saturating_sub(200)); + assert_eq!(c.softclip_trail_end, c.read_end + 300); + } + + #[test] + fn alignment_before_region_has_no_extension() { + // 100M200S entirely before the region: no extraction, no extension. + let c = cuts("100M200S", 500, 1000, 2000); assert_eq!(c.read_end, 0); + assert_eq!(c.softclip_trail_end, 0); + } + + #[test] + fn trailing_softclip_extension_survives_padding() { + // Same trailing-clip read, but the desired window is padded 5bp on each side + // (as a caller applying flanks would pass). The clip is still detected because + // the alignment ends before the padded window end. + let c = cuts("600M400S", 500, 995, 2005); + assert_eq!(c.softclip_trail_end, c.read_end + 400); } // --- read_pos_at_ref --- diff --git a/tests/integration.rs b/tests/integration.rs index 91e43ed..9d14b1f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -42,7 +42,8 @@ fn to_cigar_ops_trait_accessible_from_crate_root() { #[test] fn read_cuts_type_accessible() { let ops = "10M".to_cigar_ops().unwrap(); - let _cuts: ReadCuts = get_read_cuts(&ops, 1, 3, 7); + // 10M from align_start=1 → align_end (one-past) = 11. + let _cuts: ReadCuts = get_read_cuts(&ops, 1, 11, 3, 7); } // --- PafIndex and PafRecord via lib root --- @@ -128,7 +129,8 @@ fn qscore_below_threshold_detection() { #[test] fn get_read_cuts_insertion_captured() { let ops = "3M5I4M".to_cigar_ops().unwrap(); - let cuts = get_read_cuts(&ops, 1, 3, 7); + // 3M+4M consume 7 ref bases from align_start=1 → align_end (one-past) = 8. + let cuts = get_read_cuts(&ops, 1, 8, 3, 7); assert_eq!(cuts.read_end - cuts.read_start, 9); // 1M + 5I + 3M } From ffdede5ce27d1dc2c2ce28e5c1bb47cdff1386ce Mon Sep 17 00:00:00 2001 From: Psy-Fer Date: Tue, 21 Jul 2026 14:30:32 +1000 Subject: [PATCH 4/6] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f49536e..013e3bc 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ TODO.md *.bam /docs/book/ *.paf.idx +bedpull_wishlist.md # Benchmark harness: local config (absolute paths, machine-specific) and # generated output/cache are not checked in — only the scripts/templates are. From 3d442debebf0f6e76ead2bf0584f823cd7093c68 Mon Sep 17 00:00:00 2001 From: Psy-Fer Date: Tue, 21 Jul 2026 14:38:28 +1000 Subject: [PATCH 5/6] Fix spurious missing_left=1bp on fully-spanning BAM/CRAM reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_bam_reads/get_cram_reads returned ref_start/ref_end in the 1-based CIGAR-walk frame, but the |missing_left/right header suffix compares them against 0-based BED coordinates — so every fully-spanning read was mislabelled missing_left=1bp. Normalise the returned coordinates to 0-based at the reads-layer boundary (lengths unchanged, so the partial coverage check is unaffected). Pre-existing since v0.2.0; only the header text was wrong, never the extracted sequence. Add an end-to-end regression test via the read_bed (+1-shifted) path. --- CHANGELOG.md | 6 ++++++ src/reads.rs | 20 +++++++++++++++++--- tests/integration.rs | 24 ++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 620fda4..0c5d3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ Coordinate-math correctness pass, driven by a differential test against skips a mid-op `ref_start` crossing. This affected BAM/CRAM extraction (the PAF path already routed around it via `read_pos_at_ref`). +- **Spurious `missing_left=1bp` on fully-spanning BAM/CRAM reads** — `get_bam_reads` / + `get_cram_reads` returned `ref_start`/`ref_end` in the 1-based CIGAR-walk frame while the + header suffix compared them against 0-based BED coordinates, so every spanning read was + mislabelled as missing 1bp on the left. The returned coordinates are now normalised to + 0-based (a pre-existing bug, also present in v0.2.0; extraction/sequences were never affected). + ### Changed - **`get_read_cuts` signature** is now diff --git a/src/reads.rs b/src/reads.rs index 2907176..b8c6e17 100644 --- a/src/reads.rs +++ b/src/reads.rs @@ -75,9 +75,9 @@ impl Default for BamConfig { /// - `name` — read name from the BAM record. /// - `sequence` — the extracted subsequence as raw bytes (ASCII nucleotides). /// - `quality_string` — Phred+33 encoded quality string for the extracted slice. -/// - `ref_start` / `ref_end` — the reference coordinates actually covered by the -/// extracted slice (may differ from the requested BED coordinates when the alignment -/// does not perfectly span the region boundary). +/// - `ref_start` / `ref_end` — the reference coordinates (0-based, matching BED) +/// actually covered by the extracted slice (may differ from the requested BED +/// coordinates when the alignment does not perfectly span the region boundary). /// - `haplotype` — value of the `HP` aux tag; `0` means the tag was absent (unphased). pub type BamRead = (String, Vec, String, usize, usize, u8); @@ -542,6 +542,15 @@ where .map(|i| i as u8) .unwrap_or(0); + // The CIGAR walk runs in the same 1-based frame as noodles' alignment_start + // (and read_bed's +1-shifted region bounds), so ref_start/ref_end come back + // 1-based. Callers (and the |missing_left/right header suffix) work in 0-based + // BED coordinates, so normalise here — otherwise every fully-spanning read is + // mislabelled `missing_left=1bp`. Lengths are unchanged, so the coverage check + // above is unaffected. + let ref_start = ref_start.saturating_sub(1); + let ref_end = ref_end.saturating_sub(1); + results.push((name, subseq, subqual, ref_start, ref_end, hap)); } @@ -673,6 +682,11 @@ pub fn get_cram_reads( }) .unwrap_or(0); + // Normalise ref_start/ref_end from the 1-based walk frame to 0-based BED + // coordinates, as in get_bam_reads (keeps the |missing_left/right suffix honest). + let ref_start = ref_start.saturating_sub(1); + let ref_end = ref_end.saturating_sub(1); + results.push((name, subseq, subqual, ref_start, ref_end, hap)); } Ok((results, candidates_seen)) diff --git a/tests/integration.rs b/tests/integration.rs index 9d14b1f..558f1b3 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -238,6 +238,30 @@ fn bam_mode_flanks_extend_extracted_sequence() { ); } +#[test] +fn bam_mode_spanning_reads_report_zero_based_region_coverage() { + // Regression: a fully-spanning read must report ref_start/ref_end equal to the + // 0-based BED region bounds — not shifted by 1. Built via read_bed (the +1-shifted + // path the CLI uses) so it exercises the exact frame the `missing_left=1bp` bug hit. + let bed = temp_bed("chr4\t39318077\t39318136\tRFC1\n"); + let regions = read_bed(bed.path(), false).unwrap(); + let (region, _name, _chr) = ®ions[0]; + + let bam_path = Path::new("examples/rfc1_test.bam"); + let (mut reader, header, _r) = bam_query(bam_path, "chr4:39318077-39318136"); + let query = reader.query(&header, region).expect("BAM query failed"); + let (reads, _) = + bedpull::get_bam_reads(&BamConfig::default(), query, region, 0, 0).expect("get_bam_reads"); + + assert!(!reads.is_empty()); + // Every spanning read covers the whole region, so ref_start == 39318077 (0-based BED + // start) and ref_end == 39318136 (0-based BED end) exactly — no off-by-one. + for (name, _seq, _qual, ref_start, ref_end, _hap) in &reads { + assert_eq!(*ref_start, 39318077, "read {name} ref_start off by one"); + assert_eq!(*ref_end, 39318136, "read {name} ref_end off by one"); + } +} + // --- CRAM mode end-to-end --- #[test] From 1e7497c12a289d522e00d9b7a1bb6ae1ac3722e8 Mon Sep 17 00:00:00 2001 From: Psy-Fer Date: Tue, 21 Jul 2026 15:11:07 +1000 Subject: [PATCH 6/6] bump to v0.3.0 and update docs --- CHANGELOG.md | 2 +- docs/src/concepts.md | 91 ++++++++++++++++++++++++++------------------ docs/src/library.md | 45 ++++++++++++++-------- 3 files changed, 84 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5d3ef..9bb074a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ This project uses [Semantic Versioning](https://semver.org/). --- -## [0.3.0] — unreleased +## [0.3.0] — 2026-07-21 Coordinate-math correctness pass, driven by a differential test against [bladerunner](https://github.com/Psy-Fer/bladerunner) and aimed at letting bladerunner link diff --git a/docs/src/concepts.md b/docs/src/concepts.md index bcf8f38..136c3ef 100644 --- a/docs/src/concepts.md +++ b/docs/src/concepts.md @@ -30,33 +30,39 @@ To include them, you must walk the CIGAR and count read positions and reference This is exactly what `get_read_cuts` in `src/utils.rs` does. -## The mixed 0-based / 1-based coordinate convention - -`get_read_cuts` takes three coordinate arguments with different bases: - -- `align_start`: **1-based** -- `region_start`: **0-based** (from BED) -- `region_end`: **0-based** (from BED) - -This is intentional and non-obvious. Here is why. - -### BAM coordinates are 1-based in noodles - -The noodles library represents BAM alignment positions as `Position`, which is a 1-based type. When bedpull reads `record.alignment_start()` and converts it with `usize::from(...)`, the result is a 1-based integer. This value is passed directly to `get_read_cuts` as `align_start`. - -### BED coordinates are 0-based - -The BED format is 0-based, half-open: a BED record with `start=100, end=200` refers to reference bases 100–199 (0-based). bedpull reads BED coordinates directly from the file and passes them to `get_read_cuts` as `region_start` and `region_end`. - -### The offset cancels correctly - -Inside `get_read_cuts`, the walk begins with `ref_pos = align_start` (1-based). The trigger condition is `ref_pos == region_start` or `ref_pos == region_end` (0-based). These conditions fire when the 1-based running position equals the 0-based BED coordinate. Because BED `start` is 0-based and the alignment start is 1-based, the trigger fires exactly one position earlier in 0-based space than it would if both were 1-based, which is exactly the behaviour needed: BED `start=100` means "the first base of the region is at 0-based position 100", and the 1-based ref_pos of `100` is that same base. - -In short: the mixed convention is the natural consequence of BED being 0-based and noodles BAM positions being 1-based, and the arithmetic works out correctly because the two offsets cancel. **Do not normalise `align_start` to 0-based before calling `get_read_cuts`.** +## Coordinate frames + +`get_read_cuts` takes `align_start`, `align_end`, `region_start`, and `region_end`. It is +**frame-agnostic**: all four must be in the *same* coordinate frame. The walk is relative — +`ref_pos` is initialised to `align_start` and advances one step per reference-consuming base — so +only the *difference* between the boundaries affects the returned read offsets. The returned +`ref_start`/`ref_end` come back in whatever frame you passed in. `align_end` is **exclusive**: +one position past the last reference base. + +### How BAM/CRAM mode keeps the frame consistent + +noodles represents BAM/CRAM alignment positions as 1-based `Position` values. `get_bam_reads` / +`get_cram_reads` pass `alignment_start()` as `align_start` and `alignment_end() + 1` as the +exclusive `align_end`. The region bounds come from the noodles `Region` built by `read_bed`, which +stores each BED coordinate shifted by `+1` — a workaround for `Position` being `NonZeroUsize` +(which cannot represent a BED `start` of `0`). That same `+1` shift puts the region into the same +1-based frame as the alignment positions, so the walk is internally consistent. Before returning, +both functions normalise `ref_start`/`ref_end` back to 0-based, so callers — and the +`|missing_left`/`|missing_right` header suffix — see plain BED coordinates. (Skipping that +normalisation is what caused the historical `missing_left=1bp`-on-every-read bug.) + +Do not put `align_start` and the region bounds in different frames — mixing them shifts every +boundary by one. ### PAF coordinates -PAF `target_start` is 0-based. However, bedpull passes it to `get_read_cuts` as `align_start` (the 1-based slot). In practice this means the PAF-mode walk starts at one position "before" the true alignment start relative to what the BAM walk does. The PAF integration (`get_paf_reads` in `src/reads.rs`) was developed and validated against the RFC1 test case, and the extracted lengths are correct (`579 bp = 59 ref bp + 520 inserted bp`). The test in `src/main.rs::tests::get_read_cuts_rfc1_captures_insertion` pins this behaviour at the CIGAR-cut level. Any change to the coordinate handling must keep that test passing. +PAF-mode extraction does **not** go through `get_read_cuts`. Because a single BED window's two +edges can fall on two *different* chained PAF records, `get_paf_reads` resolves each edge +independently with `read_pos_at_ref`, a single-boundary reference→read mapper. PAF `target_start` +is 0-based and is used consistently with 0-based region bounds throughout that path. It is +validated against the RFC1 test case (`579 bp = 59 ref bp + 520 inserted bp`): +`src/main.rs::tests::get_read_cuts_rfc1_captures_insertion` pins the CIGAR-cut math and the +`get_paf_reads` tests in `src/reads.rs` pin the end-to-end extraction. Note that the CIGAR-cut math and the final extracted FASTA sequence are two separate steps: `get_paf_reads` converts the 0-based half-open cut coordinates into query-contig coordinates and @@ -68,23 +74,32 @@ silently shifts the extracted sequence by one base without changing the *reporte Soft-clipped bases (`S`) are present in the read sequence but are not aligned to the reference. Like insertions, they advance the read position without advancing the reference position. `get_read_cuts` handles them the same way as `I` operations, so soft-clipped bases at the start of a read shift the read-position cursor before the first aligned base. +`ReadCuts` also reports `softclip_lead_start` and `softclip_trail_end` — the read offsets of any leading/trailing soft-clip runs. A caller can use these to optionally extend an extraction into clipped "expansion" sequence that sits just outside the aligned window (bladerunner uses this for flanking-expansion detection). When there is no such extension they equal `read_start`/`read_end` respectively. + ## Partial overlaps -When a read's alignment starts after `region_start`, the `region_start` trigger in `get_read_cuts` never fires (because `ref_pos` is initialised to `align_start`, which is already past `region_start`). If `region_end` is subsequently reached, the position is stored in `read_start` (because `start == 0` at that point, indicating the start sentinel has not been set). `read_end` remains `0`. +`get_read_cuts` clamps its fire-on boundaries to the alignment's own span: +`ref_start = max(region_start, align_start)` and `ref_end = min(region_end, align_end)`. A read +that only partially overlaps the window therefore still produces a real slice — `ref_start` / +`ref_end` report the covered sub-span — instead of a sentinel. `read_end == 0` now means exactly +one thing: the alignment never reached the window at all. + +The start boundary is tracked with an explicit `found_start` flag (not a `start > 0` sentinel) and +is recorded at op entry when `ref_pos == ref_start`. This is what makes `read_start == 0` a valid +result: a read whose alignment begins exactly at the window start correctly yields `read_start = 0` +instead of being dropped (the boundary-coincidence bug fixed in 0.3.0). -`resolve_cuts` in `src/reads.rs` (a private helper shared by `get_bam_reads` and -`get_cram_reads`) detects this sentinel pattern when `BamConfig::partial` is `true` and remaps -the fields into the actual `(read_start, read_end, ref_start, ref_end)` covered by the returned -slice: +`resolve_cuts` in `src/reads.rs` (a private helper shared by `get_bam_reads` and `get_cram_reads`) +then applies the spanning policy: -- If `align_start > region_start` (left-partial or contained): the read is extracted from position `0` to `read_cuts.read_start` (where the `region_end` was reached). -- If `read_cuts.read_end == 0` and partial mode is on (right-partial): the read is extracted from `read_cuts.read_start` to the end of the read. -- If `read_cuts.read_end == 0` and partial mode is off: the read is skipped. +- `read_end == 0` (no overlap): the read is skipped. +- Non-partial mode and the alignment does not fully cover the region (i.e. + `align_start <= region_start && align_end >= region_end` is false): the read is skipped. +- Otherwise: the clamped `(read_start, read_end, ref_start, ref_end)` is returned unchanged. -The corrected `ref_start`/`ref_end` this produces are what drive the `missing_left=Nbp` / -`missing_right=Nbp` header annotations for `--partial` reads that don't fully cover the -requested window. +The resulting `ref_start`/`ref_end` (normalised to 0-based) drive the `missing_left=Nbp` / +`missing_right=Nbp` header annotations for `--partial` reads that don't fully cover the requested +window. -The unit tests for `get_read_cuts`'s underlying cut semantics are in `src/utils.rs` under the -`// --- partial overlap ---` section; the tests for `resolve_cuts`'s field-remapping on top of -that are in `src/reads.rs` (`resolve_cuts_*`). +The unit tests for `get_read_cuts`'s cut semantics are in `src/utils.rs`; the tests for +`resolve_cuts`'s spanning policy are in `src/reads.rs` (`resolve_cuts_*`). diff --git a/docs/src/library.md b/docs/src/library.md index ec6ecf0..933b8ca 100644 --- a/docs/src/library.md +++ b/docs/src/library.md @@ -6,7 +6,7 @@ bedpull is published as a Rust library crate as well as a binary. You can use it ```toml [dependencies] -bedpull = "0.2" +bedpull = "0.3" ``` ## Import paths @@ -88,13 +88,22 @@ logic with data from another source. use anyhow::Result; use bedpull::{ToCigarOps, get_read_cuts}; -fn show_cuts(cigar: &str, align_start: usize, region_start: usize, region_end: usize) -> Result<()> { +fn show_cuts( + cigar: &str, + align_start: usize, + align_end: usize, + region_start: usize, + region_end: usize, +) -> Result<()> { // Parse a raw CIGAR string from a PAF cg:Z: tag or any str source. let ops = cigar.to_cigar_ops()?; - // align_start is 1-based (as from a BAM record or PAF target_start field). - // region_start / region_end are 0-based (as from a BED file). - let cuts = get_read_cuts(&ops, align_start, region_start, region_end); + // align_start / align_end and region_start / region_end must all be in the same + // coordinate frame (the walk is relative). align_end is exclusive — one past the + // last reference base. region_start / region_end are the desired window (expand + // them yourself if you want flanks); get_read_cuts clamps its fire-on boundaries + // to the alignment span internally. + let cuts = get_read_cuts(&ops, align_start, align_end, region_start, region_end); println!( "read slice: [{}..{}] ({} bases)", @@ -112,12 +121,12 @@ fn show_cuts(cigar: &str, align_start: usize, region_start: usize, region_end: u fn main() -> Result<()> { // A read aligned at ref position 1, with a 5-base insertion inside the region. - // CIGAR: 3M5I4M - // Region: ref bases 3 to 7 (0-based BED coordinates) + // CIGAR: 3M5I4M consumes 7 reference bases, so align_end = 1 + 7 = 8. + // Region: ref bases 3 to 7. // // Expected: the insertion is captured, so the slice is 9 bases // (1 match + 5 inserted + 3 match), not 4 reference bases. - show_cuts("3M5I4M", 1, 3, 7)?; + show_cuts("3M5I4M", 1, 8, 3, 7)?; Ok(()) } ``` @@ -138,7 +147,7 @@ cost in PAF-mode extraction once you're processing more than a handful of alignm ```rust use anyhow::Result; -use bedpull::{PafIndex, get_paf_reads, write_fasta_record}; +use bedpull::{PafIndex, StitchConfig, get_paf_reads, write_fasta_record}; use noodles::fasta; use std::fs::File; use std::io::{self, BufReader}; @@ -177,6 +186,7 @@ fn extract_from_paf( region_end, 0, // lflank 0, // rflank + StitchConfig::default(), // no cross-record stitching false, // debug )?; @@ -197,12 +207,17 @@ and call `get_paf_reads` once per region, exactly what `bedpull`'s own CLI does ## Notes on the coordinate convention -`get_read_cuts` uses a **mixed coordinate system** that matches the conventions of its two callers: - -- `align_start` is **1-based** (noodles `Position` from BAM; PAF `target_start` is 0-based but is used with the CIGAR walk in a way that matches 1-based BAM behaviour; see the [Concepts](concepts.md) chapter for details). -- `region_start` and `region_end` are **0-based** (directly from BED). - -Do not normalise both to the same base before calling `get_read_cuts`. The test suite in `src/utils.rs` documents the exact expected behaviour for all edge cases. +`get_read_cuts` is **frame-agnostic**: `align_start`, `align_end`, `region_start`, and +`region_end` must all be in the *same* coordinate frame, because the walk is relative +(`ref_pos` starts at `align_start` and advances). Only the *difference* between the boundaries +determines the returned read offsets; the returned `ref_start`/`ref_end` inherit whichever frame +you passed in. `align_end` is **exclusive** (one past the last reference base) — with noodles' +1-based inclusive `alignment_end()`, add 1. + +In BAM/CRAM mode, `get_bam_reads`/`get_cram_reads` supply all four in noodles' 1-based frame and +then normalise the returned `ref_start`/`ref_end` back to 0-based (BED) before handing them to you. +See the [Concepts](concepts.md) chapter for the full derivation. The test suite in `src/utils.rs` +documents the exact expected behaviour for all edge cases. `extract_from_fasta_coords`/`extract_from_fasta_coords_reader` take `start`/`end` as **0-based half-open**, the same convention as everywhere else in the crate, and convert internally to