Skip to main content

holodeck_lib/
read.rs

1//! Simulated read pair generation.
2//!
3//! Combines fragment extraction, error model application, and read naming
4//! into complete [`ReadPair`] objects ready for FASTQ output. Computes
5//! proper CIGARs from the haplotype-to-reference coordinate mapping for
6//! golden BAM output.
7
8use noodles::sam::alignment::record::cigar::op::{Kind, Op};
9use noodles::sam::alignment::record_buf::Cigar;
10use rand::Rng;
11
12use crate::error_model::{self, ErrorModel, ReadEnd};
13use crate::fragment::{
14    Fragment, extract_read_bases, lowercase_fraction, reverse_complement, uppercase_in_place,
15};
16use crate::meth::{
17    ConversionType, MethylationAnnotation, MethylationConfig, apply_methylation_conversion,
18};
19use crate::read_naming::{TruthAlignment, encoded_pe_name, encoded_se_name, simple_name};
20
21/// A single simulated read with bases, quality scores, and metadata.
22#[derive(Debug, Clone)]
23pub struct SimulatedRead {
24    /// Read name (shared between R1 and R2 of a pair).
25    pub name: String,
26    /// Base sequence (possibly with errors applied).
27    pub bases: Vec<u8>,
28    /// Quality scores (Phred+33 encoded).
29    pub qualities: Vec<u8>,
30}
31
32/// A simulated read pair (or single read for SE mode).
33#[derive(Debug)]
34pub struct ReadPair {
35    /// First read (always present).
36    pub read1: SimulatedRead,
37    /// Second read (present only for paired-end mode).
38    pub read2: Option<SimulatedRead>,
39    /// Truth alignment for R1.
40    pub r1_truth: TruthAlignment,
41    /// Truth alignment for R2 (present only for paired-end mode).
42    pub r2_truth: Option<TruthAlignment>,
43    /// Truth CIGAR for R1, reflecting haplotype variants and adapter
44    /// soft-clipping.
45    pub r1_cigar: Cigar,
46    /// Truth CIGAR for R2. `None` for single-end reads.
47    pub r2_cigar: Option<Cigar>,
48    /// Methylation annotation, populated iff methylation simulation was
49    /// enabled for this pair. Carries the conversion type and
50    /// pre-conversion bases for the Bismark-compatible golden-BAM tag set
51    /// (`XG`, `XR`, `XM`, `YM`, `YS`, `NM`, `MD`).
52    pub methylation: Option<crate::meth::MethylationAnnotation>,
53}
54
55/// Output of building one mate of a read pair.
56struct MateOutput {
57    /// Final post-conversion, post-error bases.
58    bases: Vec<u8>,
59    /// Phred+33 qualities, length equal to `bases`.
60    qualities: Vec<u8>,
61    /// Pre-conversion bases (5'→3' read orientation). `None` when methylation
62    /// chemistry was not applied or capture was not requested.
63    pre_conversion: Option<Vec<u8>>,
64    /// CIGAR including any adapter soft-clip.
65    cigar: Cigar,
66    /// Truth alignment for this mate.
67    truth: TruthAlignment,
68}
69
70/// Apply BS chemistry to a fragment in source-strand orientation, returning
71/// the chemistry-applied bases in TOP-strand orientation (to match
72/// `Fragment::bases`).
73///
74/// In a directional library both R1 and R2 derive from the same source
75/// strand: R1 reads the BS-converted source 5'→3', R2 reads the
76/// PCR-synthesized complement (= revcomp of source 5'→3'). So chemistry
77/// must be applied once, at fragment scale, on the source strand — not
78/// independently per mate.
79///
80/// `is_forward` selects the source: top for CT (top-strand-derived
81/// fragments) or bottom for GA. The methylation table is indexed by
82/// top-strand haplotype coordinates; for GA fragments we revcomp the
83/// fragment into bottom orientation, run [`apply_methylation_conversion`]
84/// with `is_negative_strand = true` (which selects the bottom bitmap and
85/// reverses the index), then revcomp back to top orientation before
86/// returning.
87///
88/// Returns the post-chemistry top-strand bases together with whether the
89/// molecule was drawn as a conversion failure (a molecule property, the same
90/// for both mates).
91fn apply_fragment_chemistry(
92    pre_chem_top: &[u8],
93    hap_start: u32,
94    is_forward: bool,
95    haplotype_index: usize,
96    config: &MethylationConfig<'_>,
97    rng: &mut impl Rng,
98) -> (Vec<u8>, bool) {
99    let mut bases = pre_chem_top.to_vec();
100    if !is_forward {
101        reverse_complement(&mut bases);
102    }
103    let n = bases.len();
104    let conversion_failed = apply_methylation_conversion(
105        &mut bases,
106        n,
107        !is_forward,
108        hap_start,
109        haplotype_index,
110        config,
111        rng,
112    );
113    if !is_forward {
114        reverse_complement(&mut bases);
115    }
116    (bases, conversion_failed)
117}
118
119/// Build one mate (R1 or R2) from already-extracted bases.
120///
121/// `bases` is post-chemistry, post-uppercase, in read 5'→3' orientation
122/// (the same orientation [`extract_read_bases`] produces). The caller is
123/// responsible for running the ambiguity-fraction filter before invoking
124/// this function: errors mutate `bases` and advance `rng`, so rejection
125/// must happen first to avoid desynchronising the RNG stream.
126///
127/// Pipeline (per-mate): apply_errors → CIGAR + truth.
128/// Chemistry runs once at fragment scale upstream — see
129/// [`apply_fragment_chemistry`].
130#[allow(clippy::too_many_arguments)]
131fn build_mate(
132    fragment: &Fragment,
133    contig_name: &str,
134    end: ReadEnd,
135    is_negative_strand: bool,
136    mut bases: Vec<u8>,
137    pre_conversion: Option<Vec<u8>>,
138    adapter_bases: usize,
139    model: &impl ErrorModel,
140    rng: &mut impl Rng,
141) -> MateOutput {
142    let frag_len = fragment.bases.len();
143    let genomic = frag_len.min(bases.len());
144    let right_start = frag_len.saturating_sub(genomic);
145
146    // Fragment ref_positions are always in ascending (forward) reference
147    // order; negative-strand reads take from the right end of the fragment.
148    let positions = if is_negative_strand {
149        &fragment.ref_positions[right_start..frag_len]
150    } else {
151        &fragment.ref_positions[..genomic]
152    };
153
154    let (n_errors, qualities) = error_model::apply_errors(model, &mut bases, end, rng);
155
156    let cigar = cigar_from_ref_positions(positions, adapter_bases, is_negative_strand);
157
158    let ref_pos = if fragment.ref_positions.is_empty() {
159        0
160    } else if is_negative_strand {
161        fragment.ref_positions[right_start] + 1
162    } else {
163        fragment.ref_positions[0] + 1
164    };
165
166    #[expect(clippy::cast_possible_truncation, reason = "fragment length fits in u32")]
167    let fragment_length = frag_len as u32;
168
169    let truth = TruthAlignment {
170        contig: contig_name.to_string(),
171        position: ref_pos,
172        is_forward: match end {
173            ReadEnd::Read1 => fragment.is_forward,
174            ReadEnd::Read2 => !fragment.is_forward,
175        },
176        haplotype: fragment.haplotype_index,
177        fragment_length,
178        n_errors,
179    };
180
181    MateOutput { bases, qualities, pre_conversion, cigar, truth }
182}
183
184/// Generate a simulated read pair from a fragment.
185///
186/// Extracts R1 and optionally R2 bases from the fragment, applies the error
187/// model, constructs read names with truth information, and computes CIGARs
188/// from the fragment's reference coordinate mapping.
189///
190/// Returns `None` if either R1 or R2 has a lowercase-base fraction greater
191/// than `max_n_frac` (i.e. too many bases came from ambiguity-resolved
192/// reference positions); the caller should resample. See the [`fragment`]
193/// module documentation for the lowercase-marker convention.
194///
195/// [`fragment`]: crate::fragment
196///
197/// # Arguments
198/// * `fragment` — The source fragment with bases and reference positions.
199/// * `contig_name` — Contig name for read naming.
200/// * `read_num` — 1-based read pair number.
201/// * `read_length` — Desired read length.
202/// * `paired` — Whether to generate both R1 and R2.
203/// * `adapter_r1` — Adapter sequence for R1.
204/// * `adapter_r2` — Adapter sequence for R2.
205/// * `max_n_frac` — Reject the pair if R1 or R2 has a lowercase fraction
206///   exceeding this threshold. Use `1.0` to disable.
207/// * `error_model` — Error model to apply.
208/// * `simple_names` — Use simple names instead of encoded truth names.
209/// * `methylation` — Optional methylation chemistry configuration. When
210///   `Some`, the qualifying class of cytosines (unmethylated under em-seq,
211///   methylated under TAPS) in the genomic portion of each read is
212///   converted to T with probability `conversion_rate` before the error
213///   model is applied.
214/// * `capture_pre_conversion` — When `true` AND `methylation` is `Some`,
215///   capture the pre-conversion bases of each mate for the `YS:Z` golden-BAM
216///   tag. Has no effect when `methylation` is `None`.
217/// * `rng` — Random number generator.
218#[allow(clippy::too_many_arguments)] // Orchestrator for the read-pair pipeline
219pub fn generate_read_pair(
220    fragment: &Fragment,
221    contig_name: &str,
222    read_num: u64,
223    read_length: usize,
224    paired: bool,
225    adapter_r1: &[u8],
226    adapter_r2: &[u8],
227    max_n_frac: f64,
228    model: &impl ErrorModel,
229    simple_names: bool,
230    methylation: Option<&MethylationConfig>,
231    capture_pre_conversion: bool,
232    rng: &mut impl Rng,
233) -> Option<ReadPair> {
234    let frag_len = fragment.bases.len();
235    let adapter_bases = read_length.saturating_sub(frag_len.min(read_length));
236
237    let r1_negative_strand = !fragment.is_forward;
238    let r2_negative_strand = fragment.is_forward;
239
240    // Step 1 — ambiguity filter on raw extracted bases (case preserved so
241    // lowercase_fraction can detect ambiguity-resolved positions). Reject
242    // BEFORE drawing any error-model RNG so a rejection doesn't desync the
243    // stream.
244    let r1_raw = extract_read_bases(&fragment.bases, read_length, adapter_r1, r1_negative_strand);
245    if lowercase_fraction(&r1_raw) > max_n_frac {
246        return None;
247    }
248    if paired {
249        let r2_raw =
250            extract_read_bases(&fragment.bases, read_length, adapter_r2, r2_negative_strand);
251        if lowercase_fraction(&r2_raw) > max_n_frac {
252            return None;
253        }
254    }
255
256    // Step 2 — uppercased pre-chemistry top-strand fragment. This is the
257    // single source from which both R1 and R2 are derived (post-chemistry).
258    // Capturing pre-conversion bases for YS:Z is done by extracting from
259    // this same buffer before chemistry runs.
260    let mut pre_chem_top = fragment.bases.clone();
261    uppercase_in_place(&mut pre_chem_top);
262
263    // Step 3 — apply chemistry once, at fragment scale, on the source
264    // strand. This produces top-strand-oriented bases reflecting the
265    // appropriate strand's chemistry (em-seq / TAPS, with CpG context
266    // resolved per-haplotype).
267    let (post_chem_top, conversion_failed) = match methylation {
268        Some(mc) => apply_fragment_chemistry(
269            &pre_chem_top,
270            fragment.hap_start,
271            fragment.is_forward,
272            fragment.haplotype_index,
273            mc,
274            rng,
275        ),
276        None => (pre_chem_top.clone(), false),
277    };
278
279    // Step 4 — derive per-mate read bases from the chemistry-applied
280    // fragment. extract_read_bases handles orientation (revcomp when
281    // is_negative_strand) and adapter padding for short fragments.
282    let r1_bases = extract_read_bases(&post_chem_top, read_length, adapter_r1, r1_negative_strand);
283
284    // Capture pre-conversion mate bases for YS:Z when requested. Read these
285    // from `pre_chem_top` so the orientation matches the post-chemistry
286    // mate bases (extract_read_bases revcomps the same way for both).
287    let want_pre = capture_pre_conversion && methylation.is_some();
288    let r1_pre_conversion = want_pre
289        .then(|| extract_read_bases(&pre_chem_top, read_length, adapter_r1, r1_negative_strand));
290
291    let r1 = build_mate(
292        fragment,
293        contig_name,
294        ReadEnd::Read1,
295        r1_negative_strand,
296        r1_bases,
297        r1_pre_conversion,
298        adapter_bases,
299        model,
300        rng,
301    );
302
303    if !paired {
304        let name =
305            if simple_names { simple_name(read_num) } else { encoded_se_name(read_num, &r1.truth) };
306
307        let methylation_annotation = methylation.map(|_| MethylationAnnotation {
308            conversion_type: ConversionType::from_strand(fragment.is_forward),
309            conversion_failed,
310            r1_pre_conversion_bases: r1.pre_conversion,
311            r2_pre_conversion_bases: None,
312            r1_call_tags: None,
313            r2_call_tags: None,
314        });
315
316        return Some(ReadPair {
317            read1: SimulatedRead { name, bases: r1.bases, qualities: r1.qualities },
318            read2: None,
319            r1_truth: r1.truth,
320            r2_truth: None,
321            r1_cigar: r1.cigar,
322            r2_cigar: None,
323            methylation: methylation_annotation,
324        });
325    }
326
327    let r2_bases = extract_read_bases(&post_chem_top, read_length, adapter_r2, r2_negative_strand);
328    let r2_pre_conversion = want_pre
329        .then(|| extract_read_bases(&pre_chem_top, read_length, adapter_r2, r2_negative_strand));
330    let r2 = build_mate(
331        fragment,
332        contig_name,
333        ReadEnd::Read2,
334        r2_negative_strand,
335        r2_bases,
336        r2_pre_conversion,
337        adapter_bases,
338        model,
339        rng,
340    );
341
342    let name = if simple_names {
343        simple_name(read_num)
344    } else {
345        encoded_pe_name(read_num, &r1.truth, &r2.truth)
346    };
347
348    let methylation_annotation = methylation.map(|_| MethylationAnnotation {
349        conversion_type: ConversionType::from_strand(fragment.is_forward),
350        conversion_failed,
351        r1_pre_conversion_bases: r1.pre_conversion,
352        r2_pre_conversion_bases: r2.pre_conversion,
353        r1_call_tags: None,
354        r2_call_tags: None,
355    });
356
357    Some(ReadPair {
358        read1: SimulatedRead { name: name.clone(), bases: r1.bases, qualities: r1.qualities },
359        read2: Some(SimulatedRead { name, bases: r2.bases, qualities: r2.qualities }),
360        r1_truth: r1.truth,
361        r2_truth: Some(r2.truth),
362        r1_cigar: r1.cigar,
363        r2_cigar: Some(r2.cigar),
364        methylation: methylation_annotation,
365    })
366}
367
368/// Compute a CIGAR from a slice of ascending reference positions, plus
369/// optional adapter soft-clipping.
370///
371/// Positions must be in ascending order (forward strand). Consecutive
372/// positions incrementing by 1 produce M ops, same position produces I ops,
373/// and gaps produce D ops.
374///
375/// This works for both R1 and R2: BAM CIGARs are always expressed in forward
376/// reference order from the leftmost aligned position, so even negative-strand
377/// reads use ascending positions.
378///
379/// Adapter bases are appended as a soft-clip (S) operation. For
380/// negative-strand reads (`negative_strand = true`) the adapter is sequenced
381/// at the 3' end of the read but sits at the left (5') end of the stored BAM
382/// record after reverse-complementing, so the S op is placed at the *start*
383/// of the CIGAR. For forward-strand reads the S op is placed at the *end*.
384#[must_use]
385pub fn cigar_from_ref_positions(
386    positions: &[u32],
387    adapter_bases: usize,
388    negative_strand: bool,
389) -> Cigar {
390    let mut ops: Vec<Op> = Vec::new();
391
392    if positions.is_empty() {
393        if adapter_bases > 0 {
394            ops.push(Op::new(Kind::SoftClip, adapter_bases));
395        }
396        return Cigar::from(ops);
397    }
398
399    let mut match_run: usize = 1; // First base is always a match.
400    let mut ins_run: usize = 0;
401
402    for i in 1..positions.len() {
403        let prev = positions[i - 1];
404        let curr = positions[i];
405
406        if curr == prev + 1 {
407            // Sequential ascending position: flush any insertion, extend match.
408            if ins_run > 0 {
409                ops.push(Op::new(Kind::Insertion, ins_run));
410                ins_run = 0;
411            }
412            match_run += 1;
413        } else if curr == prev {
414            // Same position: insertion base. Flush match run, extend insertion.
415            if match_run > 0 {
416                ops.push(Op::new(Kind::Match, match_run));
417                match_run = 0;
418            }
419            ins_run += 1;
420        } else {
421            // Gap in positions: deletion. Flush current runs.
422            if ins_run > 0 {
423                ops.push(Op::new(Kind::Insertion, ins_run));
424                ins_run = 0;
425            }
426            if match_run > 0 {
427                ops.push(Op::new(Kind::Match, match_run));
428            }
429            let gap = curr.saturating_sub(prev).saturating_sub(1);
430            if gap > 0 {
431                ops.push(Op::new(Kind::Deletion, gap as usize));
432            }
433            match_run = 1; // Current base starts a new match.
434        }
435    }
436
437    // Flush remaining runs.
438    if ins_run > 0 {
439        ops.push(Op::new(Kind::Insertion, ins_run));
440    }
441    if match_run > 0 {
442        ops.push(Op::new(Kind::Match, match_run));
443    }
444
445    // Adapter bases as soft-clip. Negative-strand reads are stored
446    // reverse-complemented in BAM, so the adapter (at the 3' end in read
447    // order) moves to the left (5') end of the record — a leading S op.
448    if adapter_bases > 0 {
449        if negative_strand {
450            ops.insert(0, Op::new(Kind::SoftClip, adapter_bases));
451        } else {
452            ops.push(Op::new(Kind::SoftClip, adapter_bases));
453        }
454    }
455
456    Cigar::from(ops)
457}
458
459/// Format a CIGAR as a human-readable string (e.g. "50M3I20M2S").
460#[must_use]
461pub fn cigar_to_string(cigar: &Cigar) -> String {
462    use std::fmt::Write;
463    cigar.as_ref().iter().fold(String::new(), |mut s, op| {
464        let kind_char = match op.kind() {
465            Kind::Match => 'M',
466            Kind::Insertion => 'I',
467            Kind::Deletion => 'D',
468            Kind::SoftClip => 'S',
469            Kind::HardClip => 'H',
470            Kind::Skip => 'N',
471            Kind::Pad => 'P',
472            Kind::SequenceMatch => '=',
473            Kind::SequenceMismatch => 'X',
474        };
475        let _ = write!(s, "{}{kind_char}", op.len());
476        s
477    })
478}
479
480#[cfg(test)]
481mod tests {
482    use rand::SeedableRng;
483    use rand::rngs::SmallRng;
484
485    use super::*;
486    use crate::error_model::illumina::IlluminaErrorModel;
487
488    /// Build a simple fragment for testing.
489    fn test_fragment(bases: &[u8], ref_start: u32) -> Fragment {
490        #[expect(clippy::cast_possible_truncation, reason = "test data is small")]
491        let ref_positions: Vec<u32> = (ref_start..ref_start + bases.len() as u32).collect();
492        Fragment {
493            bases: bases.to_vec(),
494            ref_positions,
495            ref_start,
496            hap_start: ref_start,
497            is_forward: true,
498            haplotype_index: 0,
499        }
500    }
501
502    // --- CIGAR generation tests ---
503
504    #[test]
505    fn test_cigar_all_match() {
506        let cigar = cigar_from_ref_positions(&[0, 1, 2, 3, 4], 0, false);
507        assert_eq!(cigar_to_string(&cigar), "5M");
508    }
509
510    #[test]
511    fn test_cigar_with_insertion() {
512        // Positions 0,1,2,2,2,3,4: two inserted bases at ref pos 2.
513        let cigar = cigar_from_ref_positions(&[0, 1, 2, 2, 2, 3, 4], 0, false);
514        assert_eq!(cigar_to_string(&cigar), "3M2I2M");
515    }
516
517    #[test]
518    fn test_cigar_with_deletion() {
519        // Gap from 2 to 5: 2 deleted ref bases.
520        let cigar = cigar_from_ref_positions(&[0, 1, 2, 5, 6], 0, false);
521        assert_eq!(cigar_to_string(&cigar), "3M2D2M");
522    }
523
524    #[test]
525    fn test_cigar_with_adapter_softclip_forward() {
526        // Forward-strand: adapter is a trailing soft-clip.
527        let cigar = cigar_from_ref_positions(&[0, 1, 2], 2, false);
528        assert_eq!(cigar_to_string(&cigar), "3M2S");
529    }
530
531    #[test]
532    fn test_cigar_with_adapter_softclip_negative_strand() {
533        // Negative-strand: adapter moves to a leading soft-clip after RC.
534        let cigar = cigar_from_ref_positions(&[0, 1, 2], 2, true);
535        assert_eq!(cigar_to_string(&cigar), "2S3M");
536    }
537
538    #[test]
539    fn test_cigar_all_adapter() {
540        // All-adapter read: placement doesn't matter, but it still round-trips.
541        let cigar = cigar_from_ref_positions(&[], 5, false);
542        assert_eq!(cigar_to_string(&cigar), "5S");
543    }
544
545    #[test]
546    fn test_cigar_with_insertion_and_deletion() {
547        // Insertion at pos 2 (two extra bases), then deletion of 2 ref bases.
548        let cigar = cigar_from_ref_positions(&[0, 1, 2, 2, 5, 6], 0, false);
549        assert_eq!(cigar_to_string(&cigar), "3M1I2D2M");
550    }
551
552    #[test]
553    fn test_cigar_with_adapter_and_deletion() {
554        let cigar = cigar_from_ref_positions(&[0, 1, 4, 5], 3, false);
555        assert_eq!(cigar_to_string(&cigar), "2M2D2M3S");
556    }
557
558    #[test]
559    fn test_cigar_single_base() {
560        let cigar = cigar_from_ref_positions(&[42], 0, false);
561        assert_eq!(cigar_to_string(&cigar), "1M");
562    }
563
564    #[test]
565    fn test_cigar_high_positions() {
566        // Negative-strand R2: positions start from a high offset (ascending),
567        // no adapter — CIGAR is identical to forward strand.
568        let cigar = cigar_from_ref_positions(&[100, 101, 102, 103, 104], 0, true);
569        assert_eq!(cigar_to_string(&cigar), "5M");
570    }
571
572    // --- Read pair generation tests ---
573
574    #[test]
575    fn test_generate_pe_read_pair() {
576        let fragment = test_fragment(b"ACGTACGTACGTACGTACGT", 100);
577        let model = IlluminaErrorModel::new(10, 0.0, 0.0);
578        let mut rng = SmallRng::seed_from_u64(42);
579
580        let pair = generate_read_pair(
581            &fragment, "chr1", 1, 10, true, b"ADAPTER", b"ADAPTER", 1.0, &model, false, None,
582            false, &mut rng,
583        )
584        .expect("no ambiguous bases — should not reject");
585
586        assert_eq!(pair.read1.bases, b"ACGTACGTAC");
587        assert!(pair.read2.is_some());
588        assert_eq!(pair.r1_truth.position, 101);
589        assert!(pair.r2_truth.is_some());
590        assert_eq!(cigar_to_string(&pair.r1_cigar), "10M");
591        assert_eq!(cigar_to_string(pair.r2_cigar.as_ref().unwrap()), "10M");
592    }
593
594    #[test]
595    fn test_generate_se_read() {
596        let fragment = test_fragment(b"ACGTACGTAC", 100);
597        let model = IlluminaErrorModel::new(10, 0.0, 0.0);
598        let mut rng = SmallRng::seed_from_u64(42);
599
600        let pair = generate_read_pair(
601            &fragment, "chr1", 5, 10, false, b"ADAPTER", b"ADAPTER", 1.0, &model, false, None,
602            false, &mut rng,
603        )
604        .unwrap();
605
606        assert!(pair.read2.is_none());
607        assert!(pair.r2_cigar.is_none());
608        assert_eq!(cigar_to_string(&pair.r1_cigar), "10M");
609    }
610
611    #[test]
612    fn test_adapter_cigar_softclip() {
613        // Forward-strand fragment: R1 is positive-strand (trailing S), R2 is
614        // negative-strand (leading S after BAM reverse-complement).
615        let fragment = test_fragment(b"AC", 0);
616        let model = IlluminaErrorModel::new(5, 0.0, 0.0);
617        let mut rng = SmallRng::seed_from_u64(42);
618
619        let pair = generate_read_pair(
620            &fragment, "chr1", 1, 5, true, b"TTTTT", b"GGGGG", 1.0, &model, false, None, false,
621            &mut rng,
622        )
623        .unwrap();
624
625        assert_eq!(cigar_to_string(&pair.r1_cigar), "2M3S");
626        assert_eq!(cigar_to_string(pair.r2_cigar.as_ref().unwrap()), "3S2M");
627    }
628
629    #[test]
630    fn test_simple_name_mode() {
631        let fragment = test_fragment(b"ACGT", 0);
632        let model = IlluminaErrorModel::new(4, 0.0, 0.0);
633        let mut rng = SmallRng::seed_from_u64(42);
634
635        let pair = generate_read_pair(
636            &fragment, "chr1", 42, 4, true, b"A", b"A", 1.0, &model, true, None, false, &mut rng,
637        )
638        .unwrap();
639
640        assert_eq!(pair.read1.name, "holodeck::42");
641    }
642
643    #[test]
644    fn test_quality_scores_correct_length() {
645        let fragment = test_fragment(b"ACGTACGTAC", 0);
646        let model = IlluminaErrorModel::new(10, 0.001, 0.01);
647        let mut rng = SmallRng::seed_from_u64(42);
648
649        let pair = generate_read_pair(
650            &fragment, "chr1", 1, 10, true, b"A", b"A", 1.0, &model, false, None, false, &mut rng,
651        )
652        .unwrap();
653
654        assert_eq!(pair.read1.qualities.len(), 10);
655        assert_eq!(pair.read2.as_ref().unwrap().qualities.len(), 10);
656    }
657
658    #[test]
659    fn test_rejects_when_r1_exceeds_max_n_frac() {
660        // Fragment entirely lowercase: every base on R1 (forward) is
661        // flagged, lowercase_fraction == 1.0, which exceeds any threshold < 1.
662        let mut fragment = test_fragment(b"acgtacgtac", 100);
663        // Mark as forward so R1 is the positive-strand, genomic-bases read.
664        fragment.is_forward = true;
665        let model = IlluminaErrorModel::new(10, 0.0, 0.0);
666        let mut rng = SmallRng::seed_from_u64(42);
667
668        let pair = generate_read_pair(
669            &fragment, "chr1", 1, 10, true, b"ADAPTER", b"ADAPTER", 0.5, &model, false, None,
670            false, &mut rng,
671        );
672
673        assert!(pair.is_none(), "all-lowercase fragment should be rejected at threshold 0.5");
674    }
675
676    #[test]
677    fn test_accepts_when_lowercase_below_threshold() {
678        // 3 lowercase out of 10 = 0.3, below threshold 0.5 — should accept
679        // and also emit uppercase bases.
680        let fragment = test_fragment(b"ACaGcTAtCA", 0);
681        let model = IlluminaErrorModel::new(10, 0.0, 0.0);
682        let mut rng = SmallRng::seed_from_u64(42);
683
684        let pair = generate_read_pair(
685            &fragment, "chr1", 1, 10, true, b"ADAPTER", b"ADAPTER", 0.5, &model, false, None,
686            false, &mut rng,
687        )
688        .expect("0.3 < 0.5 — should accept");
689
690        // Emitted bases must be uppercase ACGT only.
691        for &b in &pair.read1.bases {
692            assert!(matches!(b, b'A' | b'C' | b'G' | b'T' | b'N'), "r1 got {b:?}");
693        }
694        for &b in &pair.read2.as_ref().unwrap().bases {
695            assert!(matches!(b, b'A' | b'C' | b'G' | b'T' | b'N'), "r2 got {b:?}");
696        }
697    }
698
699    #[test]
700    fn test_generate_read_pair_with_em_seq_converts_forward_r1() {
701        use crate::meth::{
702            ContigMethylation, MethylationConfig, MethylationMode, MethylationTable,
703        };
704
705        let cm = ContigMethylation::from_tables(vec![MethylationTable::empty(1000)]);
706        let mc = MethylationConfig {
707            contig_methylation: &cm,
708            mode: MethylationMode::EmSeq,
709            conversion_rate: 1.0,
710            failure_rate: 0.0,
711        };
712
713        // Fragment with C's at known positions, forward strand → R1 sees them
714        // directly; with 0% methylation and 100% conversion, every C in the
715        // genomic portion of R1 must become T.
716        let fragment = test_fragment(b"ACGTACGTAC", 100);
717        let model = IlluminaErrorModel::new(10, 0.0, 0.0);
718        let mut rng = SmallRng::seed_from_u64(42);
719
720        let pair = generate_read_pair(
721            &fragment,
722            "chr1",
723            1,
724            10,
725            true,
726            b"ADAPTER",
727            b"ADAPTER",
728            1.0,
729            &model,
730            false,
731            Some(&mc),
732            true,
733            &mut rng,
734        )
735        .unwrap();
736
737        // No C's should remain in R1 (genomic portion). Adapter is ADAPTER —
738        // contains a 'C' but no genomic C should remain.
739        #[expect(clippy::naive_bytecount, reason = "tiny test slice; clarity over speed")]
740        let n_c_in_r1 = pair.read1.bases.iter().filter(|&&b| b == b'C').count();
741        assert_eq!(
742            n_c_in_r1, 0,
743            "expected all C's in R1 converted, got bases {:?}",
744            pair.read1.bases
745        );
746
747        let ann = pair.methylation.as_ref().expect("methylation annotation must be set");
748        assert_eq!(ann.conversion_type, crate::meth::ConversionType::Ct);
749        // Pre-conversion R1 retains C's; post-conversion R1 does not.
750        let r1_pre = ann
751            .r1_pre_conversion_bases
752            .as_ref()
753            .expect("capture_pre_conversion=true → R1 pre-conversion bases must be Some");
754        #[expect(clippy::naive_bytecount, reason = "tiny test slice; clarity over speed")]
755        let n_c_pre = r1_pre.iter().filter(|&&b| b == b'C').count();
756        assert!(n_c_pre > 0, "pre-conversion R1 should still have C's, got {n_c_pre}");
757        assert_eq!(
758            r1_pre.len(),
759            pair.read1.bases.len(),
760            "pre-conversion length must match post-conversion read length"
761        );
762        // PE pair → R2 pre-conversion must also be present (this is a paired test).
763        assert!(
764            ann.r2_pre_conversion_bases.is_some(),
765            "PE methylation annotation must include R2 pre-conversion bases"
766        );
767    }
768
769    #[test]
770    fn test_generate_read_pair_with_em_seq_reverse_strand_yields_ga_conversion_type() {
771        use crate::meth::{
772            ContigMethylation, ConversionType, MethylationConfig, MethylationMode, MethylationTable,
773        };
774
775        let cm = ContigMethylation::from_tables(vec![MethylationTable::empty(1000)]);
776        let mc = MethylationConfig {
777            contig_methylation: &cm,
778            mode: MethylationMode::EmSeq,
779            conversion_rate: 1.0,
780            failure_rate: 0.0,
781        };
782
783        // Reverse-strand fragment → reads come from the bottom strand → XG=GA.
784        let mut fragment = test_fragment(b"ACGTACGTAC", 100);
785        fragment.is_forward = false;
786        let model = IlluminaErrorModel::new(10, 0.0, 0.0);
787        let mut rng = SmallRng::seed_from_u64(42);
788
789        let pair = generate_read_pair(
790            &fragment,
791            "chr1",
792            1,
793            10,
794            true,
795            b"ADAPTER",
796            b"ADAPTER",
797            1.0,
798            &model,
799            false,
800            Some(&mc),
801            true,
802            &mut rng,
803        )
804        .unwrap();
805
806        let ann = pair.methylation.as_ref().expect("methylation annotation must be set");
807        assert_eq!(ann.conversion_type, ConversionType::Ga);
808        assert!(ann.r2_pre_conversion_bases.is_some());
809
810        // Directional GA fragment: source strand is bottom of genome.
811        //   R1 5'→3' = c2t(bottom). All bottom C's → T. R1 has no C's.
812        //   R2 5'→3' = revcomp(c2t(bottom)). c2t(bottom) has no C's (all
813        //     converted) → revcomp has no G's. C content of R2 reflects
814        //     original-bottom G positions (= original-top C positions).
815        // So R2 has no G's (not no C's).
816        #[expect(clippy::naive_bytecount, reason = "tiny test slice; clarity over speed")]
817        let cytosines_in_r1 = pair.read1.bases.iter().filter(|&&b| b == b'C').count();
818        assert_eq!(
819            cytosines_in_r1, 0,
820            "R1 should have no C's after full conversion of source strand"
821        );
822        let r2 = pair.read2.as_ref().unwrap();
823        #[expect(clippy::naive_bytecount, reason = "tiny test slice; clarity over speed")]
824        let guanines_in_r2 = r2.bases.iter().filter(|&&b| b == b'G').count();
825        assert_eq!(
826            guanines_in_r2, 0,
827            "R2 5'→3' = revcomp(c2t(bottom)) must have no G's after full conversion"
828        );
829    }
830
831    #[test]
832    fn test_directional_r2_of_ct_fragment_is_revcomp_of_c2t_top() {
833        use crate::meth::{
834            ContigMethylation, MethylationConfig, MethylationMode, MethylationTable,
835        };
836
837        // CpG-free top "ACAGACAGACAG" (12 bp). Under em-seq full conversion:
838        //   c2t(top) = "ATAGATAGATAG"
839        // Real directional libraries: R1 reads the BS-converted source strand;
840        // R2 reads its PCR-synthesized complement. So for a CT fragment:
841        //   R1 5'→3' = c2t(top)
842        //   R2 5'→3' = revcomp(c2t(top))
843        // — and crucially NOT c2t(revcomp(top)) (= c2t(bottom)).
844        //
845        // Verified against real Twist EM-seq base composition: R2 has C
846        // content that mirrors R1 G content (within ~1pp), with R2 G content
847        // ~3% (only methylated CpGs survive). The c2t(bottom) model would
848        // give R2 ~25% G content.
849        let cm = ContigMethylation::from_tables(vec![MethylationTable::empty(100)]);
850        let mc = MethylationConfig {
851            contig_methylation: &cm,
852            mode: MethylationMode::EmSeq,
853            conversion_rate: 1.0,
854            failure_rate: 0.0,
855        };
856
857        let fragment = test_fragment(b"ACAGACAGACAG", 0);
858        let model = IlluminaErrorModel::new(12, 0.0, 0.0);
859        let mut rng = SmallRng::seed_from_u64(42);
860
861        let pair = generate_read_pair(
862            &fragment,
863            "chr1",
864            1,
865            12,
866            true,
867            b"ADAPTER",
868            b"ADAPTER",
869            1.0,
870            &model,
871            false,
872            Some(&mc),
873            false,
874            &mut rng,
875        )
876        .unwrap();
877
878        assert_eq!(&pair.read1.bases, b"ATAGATAGATAG", "R1 must equal c2t(top)");
879        let r2 = pair.read2.unwrap();
880        assert_eq!(
881            &r2.bases, b"CTATCTATCTAT",
882            "R2 must equal revcomp(c2t(top)) for directional behavior; \
883             c2t(revcomp(top)) = TTGTTTGTTTGT would indicate the wrong (per-mate) chemistry model"
884        );
885    }
886
887    #[test]
888    fn test_directional_r2_of_ga_fragment_is_revcomp_of_c2t_bottom() {
889        use crate::meth::{
890            ContigMethylation, MethylationConfig, MethylationMode, MethylationTable,
891        };
892
893        // For a GA (bottom-strand-derived) fragment, source strand is the
894        // genome's bottom strand. fragment.bases is still in TOP orientation;
895        // is_forward=false signals GA.
896        //   bottom = revcomp(top) = revcomp("ACAGACAGACAG") = "CTGTCTGTCTGT"
897        //   c2t(bottom) = "TTGTTTGTTTGT"
898        //   R1 5'→3' = c2t(bottom) = "TTGTTTGTTTGT"
899        //   R2 5'→3' = revcomp(c2t(bottom)) = "ACAAACAAACAA"
900        // Under the prior per-mate model, R2 of GA was c2t(top) =
901        // "ATAGATAGATAG" — wrong direction of chemistry for a GA fragment.
902        let cm = ContigMethylation::from_tables(vec![MethylationTable::empty(100)]);
903        let mc = MethylationConfig {
904            contig_methylation: &cm,
905            mode: MethylationMode::EmSeq,
906            conversion_rate: 1.0,
907            failure_rate: 0.0,
908        };
909
910        let mut fragment = test_fragment(b"ACAGACAGACAG", 0);
911        fragment.is_forward = false;
912        let model = IlluminaErrorModel::new(12, 0.0, 0.0);
913        let mut rng = SmallRng::seed_from_u64(42);
914
915        let pair = generate_read_pair(
916            &fragment,
917            "chr1",
918            1,
919            12,
920            true,
921            b"ADAPTER",
922            b"ADAPTER",
923            1.0,
924            &model,
925            false,
926            Some(&mc),
927            false,
928            &mut rng,
929        )
930        .unwrap();
931
932        assert_eq!(&pair.read1.bases, b"TTGTTTGTTTGT", "R1 of GA must equal c2t(bottom)");
933        let r2 = pair.read2.unwrap();
934        assert_eq!(
935            &r2.bases, b"ACAAACAAACAA",
936            "R2 of GA must equal revcomp(c2t(bottom)) for directional behavior"
937        );
938    }
939
940    #[test]
941    fn test_rejects_before_applying_errors_to_r1() {
942        // Clean forward half (becomes R1), all-lowercase reverse half (becomes
943        // R2 via reverse-complement). R1 passes the filter, but the pair
944        // must be rejected *before* R1's errors advance the RNG — so a
945        // subsequent call on a clean fragment produces identical output to
946        // skipping the rejected call entirely.
947        let mut fragment_half_bad = test_fragment(&[b'A'; 20], 0);
948        // Second half lowercase: becomes R2 when is_forward=true.
949        for b in &mut fragment_half_bad.bases[10..] {
950            *b = b'a';
951        }
952        fragment_half_bad.is_forward = true;
953
954        let clean_fragment = test_fragment(&[b'A'; 20], 0);
955        let model = IlluminaErrorModel::new(10, 0.5, 0.5); // High rate so errors are observable.
956
957        // Run A: rejected pair, then clean pair.
958        let mut rng_a = SmallRng::seed_from_u64(123);
959        let rejected = generate_read_pair(
960            &fragment_half_bad,
961            "chr1",
962            1,
963            10,
964            true,
965            b"TTTTTTTTTT",
966            b"TTTTTTTTTT",
967            0.5,
968            &model,
969            false,
970            None,
971            false,
972            &mut rng_a,
973        );
974        assert!(rejected.is_none(), "expected rejection when R2 is all-lowercase");
975        let after_reject = generate_read_pair(
976            &clean_fragment,
977            "chr1",
978            2,
979            10,
980            true,
981            b"TTTTTTTTTT",
982            b"TTTTTTTTTT",
983            0.5,
984            &model,
985            false,
986            None,
987            false,
988            &mut rng_a,
989        )
990        .unwrap();
991
992        // Run B: skip the rejected call entirely on an identical RNG seed.
993        let mut rng_b = SmallRng::seed_from_u64(123);
994        let direct = generate_read_pair(
995            &clean_fragment,
996            "chr1",
997            2,
998            10,
999            true,
1000            b"TTTTTTTTTT",
1001            b"TTTTTTTTTT",
1002            0.5,
1003            &model,
1004            false,
1005            None,
1006            false,
1007            &mut rng_b,
1008        )
1009        .unwrap();
1010
1011        // If rejection had consumed any RNG draws (for R1's apply_errors or
1012        // qualities), the clean pair's bases/qualities would diverge.
1013        assert_eq!(after_reject.read1.bases, direct.read1.bases);
1014        assert_eq!(after_reject.read1.qualities, direct.read1.qualities);
1015        assert_eq!(
1016            after_reject.read2.as_ref().unwrap().bases,
1017            direct.read2.as_ref().unwrap().bases
1018        );
1019    }
1020}