Skip to main content

holodeck_lib/
fragment.rs

1//! Fragment extraction and read base generation.
2//!
3//! Handles extracting genomic fragments from haplotypes, reverse-complement
4//! operations, adapter sequence appending for short fragments, and R1/R2
5//! base extraction from fragments.
6//!
7//! # Lowercase-as-marker convention
8//!
9//! [`crate::fasta::Fasta::load_contig`] stores bases resolved from IUPAC
10//! ambiguity codes (including `N`) in lowercase, while real `A`/`C`/`G`/`T`
11//! bases remain uppercase. Reads that carry those synthesized bases through
12//! from the reference can be detected by counting lowercase bytes via
13//! [`lowercase_fraction`], and the buffers are upper-cased in place with
14//! [`uppercase_in_place`] before being emitted.
15
16use crate::haplotype::Haplotype;
17
18/// The DNA complement of a base. Preserves the case of the input so the
19/// lowercase "synthesized from ambiguity" marker (see module docs) survives
20/// reverse-complementing for R2 reads.
21#[must_use]
22fn complement(base: u8) -> u8 {
23    let upper = match base.to_ascii_uppercase() {
24        b'A' => b'T',
25        b'T' => b'A',
26        b'C' => b'G',
27        b'G' => b'C',
28        _ => b'N',
29    };
30    if base.is_ascii_lowercase() { upper.to_ascii_lowercase() } else { upper }
31}
32
33/// Reverse-complement a DNA sequence in place.
34pub fn reverse_complement(seq: &mut [u8]) {
35    seq.reverse();
36    for base in seq.iter_mut() {
37        *base = complement(*base);
38    }
39}
40
41/// A simulated fragment extracted from a haplotype.
42///
43/// Contains the fragment bases and reference coordinate mapping, plus
44/// metadata about the source haplotype and strand.
45#[derive(Debug)]
46pub struct Fragment {
47    /// Fragment bases in the forward orientation (5' to 3').
48    pub bases: Vec<u8>,
49    /// Reference position for each base in the fragment.
50    pub ref_positions: Vec<u32>,
51    /// 0-based reference start position of the fragment.
52    pub ref_start: u32,
53    /// 0-based haplotype-coordinate start position of the fragment. Differs
54    /// from `ref_start` when there are upstream insertions or deletions on
55    /// this haplotype. Used by per-haplotype methylation lookups so that
56    /// indel-shifted CpG sites get the correct chemistry.
57    pub hap_start: u32,
58    /// Whether the fragment is on the forward strand.
59    pub is_forward: bool,
60    /// Index of the haplotype this fragment came from.
61    pub haplotype_index: usize,
62}
63
64/// Extract a fragment from a haplotype at a given position and strand.
65///
66/// The fragment is extracted in the forward orientation from the haplotype,
67/// then reverse-complemented if on the reverse strand.
68///
69/// # Arguments
70/// * `haplotype` — The haplotype to extract from.
71/// * `reference` — The full reference sequence for this contig.
72/// * `ref_start` — 0-based start position on the reference.
73/// * `fragment_len` — Desired fragment length in bases.
74/// * `is_forward` — Whether to read the forward or reverse strand.
75#[must_use]
76pub fn extract_fragment(
77    haplotype: &Haplotype,
78    reference: &[u8],
79    ref_start: u32,
80    fragment_len: usize,
81    is_forward: bool,
82) -> Fragment {
83    let (bases, ref_positions, hap_start) =
84        haplotype.extract_fragment(reference, ref_start, fragment_len);
85
86    Fragment {
87        bases,
88        ref_positions,
89        ref_start,
90        hap_start,
91        is_forward,
92        haplotype_index: haplotype.allele_index(),
93    }
94}
95
96/// Extract read bases from a fragment, handling adapter sequences for short
97/// fragments.
98///
99/// For R1: takes the first `read_length` bases from the fragment. If the
100/// fragment is shorter than `read_length`, appends adapter bases.
101///
102/// For R2: takes the last `read_length` bases from the fragment and
103/// reverse-complements them. If the fragment is shorter than `read_length`,
104/// appends adapter bases after the reverse-complemented fragment.
105///
106/// # Arguments
107/// * `fragment` — The source fragment bases.
108/// * `read_length` — Desired read length.
109/// * `adapter` — Adapter sequence to append for short fragments.
110/// * `is_r2` — If true, extract from the end and reverse complement.
111#[must_use]
112pub fn extract_read_bases(
113    fragment_bases: &[u8],
114    read_length: usize,
115    adapter: &[u8],
116    is_r2: bool,
117) -> Vec<u8> {
118    let frag_len = fragment_bases.len();
119
120    if is_r2 {
121        // R2: last `read_length` bases from fragment, reverse-complemented,
122        // then adapter.
123        let mut bases = Vec::with_capacity(read_length);
124        let take = frag_len.min(read_length);
125        let start = frag_len.saturating_sub(take);
126        bases.extend_from_slice(&fragment_bases[start..]);
127        reverse_complement(&mut bases);
128        append_adapter_and_pad(&mut bases, read_length, adapter);
129        bases
130    } else {
131        // R1: first `read_length` bases from fragment, then adapter.
132        let mut bases = Vec::with_capacity(read_length);
133        let take = frag_len.min(read_length);
134        bases.extend_from_slice(&fragment_bases[..take]);
135        append_adapter_and_pad(&mut bases, read_length, adapter);
136        bases
137    }
138}
139
140/// Fraction of lowercase ASCII bytes in `bases`.
141///
142/// Lowercase bases mark positions that were synthesized from IUPAC
143/// ambiguity codes (including `N`) at reference-load time, so this is a
144/// cheap proxy for "how much of this read came from an ambiguous reference
145/// region." Returns `0.0` for an empty slice.
146#[must_use]
147pub fn lowercase_fraction(bases: &[u8]) -> f64 {
148    if bases.is_empty() {
149        return 0.0;
150    }
151    // The ASCII 0x20 bit distinguishes lowercase from uppercase letters; any
152    // non-alphabetic byte (e.g. adapter pad) has this bit clear for our
153    // valid base values (A-Z / a-z) — so counting that bit is equivalent to
154    // counting lowercase letters.
155    let lower = bases.iter().filter(|&&b| b.is_ascii_lowercase()).count();
156    lower as f64 / bases.len() as f64
157}
158
159/// Upper-case every ASCII letter in `bases` in place.
160pub fn uppercase_in_place(bases: &mut [u8]) {
161    for b in bases.iter_mut() {
162        b.make_ascii_uppercase();
163    }
164}
165
166/// Append adapter bases and pad with N to reach `target_len`.
167fn append_adapter_and_pad(bases: &mut Vec<u8>, target_len: usize, adapter: &[u8]) {
168    if bases.len() < target_len {
169        let need = target_len - bases.len();
170        let adapter_take = need.min(adapter.len());
171        bases.extend_from_slice(&adapter[..adapter_take]);
172        // Pad with N if adapter is also too short.
173        while bases.len() < target_len {
174            bases.push(b'N');
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_reverse_complement() {
185        let mut seq = b"ACGT".to_vec();
186        reverse_complement(&mut seq);
187        assert_eq!(&seq, b"ACGT"); // palindrome
188
189        let mut seq2 = b"AACG".to_vec();
190        reverse_complement(&mut seq2);
191        assert_eq!(&seq2, b"CGTT");
192    }
193
194    #[test]
195    fn test_reverse_complement_with_n() {
196        let mut seq = b"ANGC".to_vec();
197        reverse_complement(&mut seq);
198        assert_eq!(&seq, b"GCNT");
199    }
200
201    #[test]
202    fn test_reverse_complement_preserves_lowercase() {
203        // Lowercase bases carry an ambiguity-resolved marker that must
204        // survive reverse-complementing.
205        let mut seq = b"aCgT".to_vec();
206        reverse_complement(&mut seq);
207        assert_eq!(&seq, b"AcGt");
208    }
209
210    #[test]
211    fn test_extract_r1_full_fragment() {
212        let fragment = b"ACGTACGTAC";
213        let bases = extract_read_bases(fragment, 10, b"ADAPTER", false);
214        assert_eq!(&bases, b"ACGTACGTAC");
215    }
216
217    #[test]
218    fn test_extract_r1_fragment_longer_than_read() {
219        let fragment = b"ACGTACGTAC";
220        let bases = extract_read_bases(fragment, 5, b"ADAPTER", false);
221        assert_eq!(&bases, b"ACGTA");
222    }
223
224    #[test]
225    fn test_extract_r1_short_fragment_with_adapter() {
226        let fragment = b"ACG";
227        let adapter = b"TTTTTT";
228        let bases = extract_read_bases(fragment, 8, adapter, false);
229        assert_eq!(&bases, b"ACGTTTTT");
230    }
231
232    #[test]
233    fn test_extract_r2_full_fragment() {
234        let fragment = b"ACGTACGTAC";
235        let bases = extract_read_bases(fragment, 10, b"ADAPTER", true);
236        // Reverse complement of ACGTACGTAC is GTACGTACGT
237        assert_eq!(&bases, b"GTACGTACGT");
238    }
239
240    #[test]
241    fn test_extract_r2_fragment_longer_than_read() {
242        let fragment = b"ACGTACGTAC";
243        let bases = extract_read_bases(fragment, 5, b"ADAPTER", true);
244        // Last 5 bases: CGTAC, reverse complement: GTACG
245        assert_eq!(&bases, b"GTACG");
246    }
247
248    #[test]
249    fn test_extract_r2_short_fragment_with_adapter() {
250        let fragment = b"ACG";
251        let adapter = b"TTTTTT";
252        let bases = extract_read_bases(fragment, 8, adapter, true);
253        // Reverse complement of ACG is CGT (3 bytes), then 5 adapter T's.
254        assert_eq!(&bases, b"CGTTTTTT"); // CGT + TTTTT = 8 bytes
255        assert_eq!(bases.len(), 8);
256    }
257
258    #[test]
259    fn test_lowercase_fraction_empty() {
260        assert!(lowercase_fraction(b"").abs() < 1e-12);
261    }
262
263    #[test]
264    fn test_lowercase_fraction_all_upper() {
265        assert!(lowercase_fraction(b"ACGTACGT").abs() < 1e-12);
266    }
267
268    #[test]
269    fn test_lowercase_fraction_all_lower() {
270        assert!((lowercase_fraction(b"acgtacgt") - 1.0).abs() < 1e-12);
271    }
272
273    #[test]
274    fn test_lowercase_fraction_mixed() {
275        // 3 lowercase out of 10.
276        let f = lowercase_fraction(b"ACaGcTAtCA");
277        assert!((f - 0.3).abs() < 1e-10, "expected 0.3, got {f}");
278    }
279
280    #[test]
281    fn test_lowercase_fraction_ignores_non_letters() {
282        // N-pad and '-' are non-letter / uppercase — only 'a' should count.
283        // "A-Na" is length 4 with 1 lowercase letter.
284        let f = lowercase_fraction(b"A-Na");
285        assert!((f - 0.25).abs() < 1e-10, "expected 0.25, got {f}");
286    }
287
288    #[test]
289    fn test_uppercase_in_place() {
290        let mut bases = b"aCgTnN".to_vec();
291        uppercase_in_place(&mut bases);
292        assert_eq!(&bases, b"ACGTNN");
293    }
294
295    #[test]
296    fn test_uppercase_in_place_empty() {
297        let mut bases: Vec<u8> = Vec::new();
298        uppercase_in_place(&mut bases);
299        assert!(bases.is_empty());
300    }
301
302    #[test]
303    fn test_extract_empty_fragment_all_adapter() {
304        let fragment = b"";
305        let adapter = b"AGATCGG";
306        let bases = extract_read_bases(fragment, 5, b"AGATCGG", false);
307        assert_eq!(&bases, b"AGATC");
308
309        let bases_r2 = extract_read_bases(fragment, 5, adapter, true);
310        assert_eq!(&bases_r2, b"AGATC");
311    }
312}