Skip to main content

holodeck_lib/
haplotype.rs

1use coitrees::{COITree, Interval, IntervalTree};
2
3use crate::vcf::genotype::VariantRecord;
4
5/// A single variant assigned to a specific haplotype.
6#[derive(Debug, Clone)]
7pub struct HaplotypeVariant {
8    /// 0-based reference position where the variant starts.
9    pub ref_pos: u32,
10    /// Length of the reference allele in bases. Uses `u32` to support
11    /// structural variants with large reference spans.
12    pub ref_len: u32,
13    /// Alternate allele bases.
14    pub alt_bases: Vec<u8>,
15}
16
17/// Sparse representation of one haplotype — a reference overlay of variants.
18///
19/// Instead of materializing a full haplotype sequence (which would require
20/// ~250MB per haplotype for human chr1), this stores only the differences from
21/// the reference as a sorted set of variants in a [`COITree`] for efficient
22/// range queries.
23///
24/// Fragment extraction works by walking the reference sequence and
25/// substituting alt alleles at variant positions on the fly.
26///
27/// Because coitrees requires `Copy + Default` for metadata, we store variant
28/// indices (as `u32`) in the tree and keep the actual variant data in a
29/// separate `Vec`.
30pub struct Haplotype {
31    /// 0-based haplotype allele index (e.g., 0 or 1 for diploid).
32    allele_index: usize,
33    /// Variant data, indexed by position in this vec.
34    variant_data: Vec<HaplotypeVariant>,
35    /// Interval tree mapping genomic ranges to indices in `variant_data`.
36    variant_tree: COITree<u32, u32>,
37    /// Cumulative-delta index used by [`Self::hap_position_for`]. Each entry
38    /// is `(var_end, cumulative_delta)`, sorted by `var_end` ascending.
39    /// `cumulative_delta` at index `i` is the sum of `(alt_len - ref_len)`
40    /// across all variants whose `var_end` is `<= entries[i].var_end`.
41    /// Enables `O(log n)` lookup instead of an `O(n)` scan in the
42    /// per-fragment hot path.
43    end_prefix_deltas: Vec<(u32, i64)>,
44}
45
46impl Haplotype {
47    /// Return the allele index of this haplotype.
48    #[must_use]
49    pub fn allele_index(&self) -> usize {
50        self.allele_index
51    }
52
53    /// Extract a fragment from this haplotype at the given reference
54    /// coordinates.
55    ///
56    /// Walks the reference from `ref_start` and produces `fragment_len` bases,
57    /// substituting alternate alleles where this haplotype has variants.
58    /// Returns the fragment bases, a list of reference positions
59    /// corresponding to each fragment base (for golden BAM coordinate
60    /// mapping), and the haplotype-coordinate position the fragment starts
61    /// at (for per-haplotype methylation lookups).
62    ///
63    /// # Arguments
64    /// * `reference` — Full reference sequence for this contig.
65    /// * `ref_start` — 0-based start position on the reference.
66    /// * `fragment_len` — Desired number of output bases.
67    ///
68    /// # Returns
69    /// A tuple of `(fragment_bases, ref_positions, hap_start)`:
70    /// * `fragment_bases[i]` is the base at fragment-internal position `i`.
71    /// * `ref_positions[i]` is the reference position corresponding to
72    ///   `fragment_bases[i]`. For inserted bases, the reference position is
73    ///   that of the base preceding the insertion.
74    /// * `hap_start` is the first haplotype-coordinate position covered by
75    ///   the fragment. Equals `ref_start` plus net upstream insertion length
76    ///   (insertions add bases, deletions remove bases). With no upstream
77    ///   indels it equals `ref_start`.
78    #[must_use]
79    #[expect(clippy::cast_possible_wrap, reason = "genomic coords < i32::MAX")]
80    pub fn extract_fragment(
81        &self,
82        reference: &[u8],
83        ref_start: u32,
84        fragment_len: usize,
85    ) -> (Vec<u8>, Vec<u32>, u32) {
86        let mut bases = Vec::with_capacity(fragment_len);
87        let mut ref_positions = Vec::with_capacity(fragment_len);
88
89        // Collect overlapping variant indices. We query exactly the range we
90        // need: [ref_start, ref_start + fragment_len). Deletions whose ref
91        // allele extends past our window are still caught because the tree
92        // stores them with their full ref allele span.
93        let query_end = (ref_start as usize + fragment_len).min(reference.len());
94        #[expect(
95            clippy::cast_possible_truncation,
96            reason = "query_end bounded by reference.len() which fits i32"
97        )]
98        let query_end_i32 = query_end.saturating_sub(1) as i32;
99        let mut overlapping_indices: Vec<u32> = Vec::new();
100        self.variant_tree.query(ref_start as i32, query_end_i32, |node| {
101            // clone() rather than *deref because coitrees' query callback
102            // yields &T on NEON (ARM) but T directly on nosimd (x86).
103            #[allow(clippy::clone_on_copy)]
104            overlapping_indices.push(node.metadata.clone());
105        });
106
107        // Sort variants by position for sequential processing.
108        overlapping_indices.sort_unstable_by_key(|&idx| self.variant_data[idx as usize].ref_pos);
109
110        let mut ref_pos = ref_start as usize;
111        let mut var_idx = 0;
112
113        // Advance past variants entirely before our window, and handle
114        // variants that start before ref_start but span into it (e.g., a
115        // deletion that started upstream).
116        while var_idx < overlapping_indices.len() {
117            let var = &self.variant_data[overlapping_indices[var_idx] as usize];
118            let var_end = var.ref_pos as usize + var.ref_len as usize;
119            if var_end <= ref_pos {
120                // Variant is entirely before our window — skip it.
121                var_idx += 1;
122            } else if (var.ref_pos as usize) < ref_pos {
123                // Variant starts before our window but spans into it.
124                // For a deletion, the ref bases are consumed — skip past them.
125                // For an insertion at this position, the alt bases were already
126                // partially consumed upstream, so we skip the variant entirely.
127                ref_pos = var_end;
128                var_idx += 1;
129            } else {
130                break;
131            }
132        }
133
134        // Compute the haplotype start position from the post-skip `ref_pos`
135        // rather than the original `ref_start`. When `ref_start` lands inside
136        // a deletion span, the pre-loop above has already advanced `ref_pos`
137        // to the first surviving reference base; deriving `hap_start` from
138        // that post-skip coordinate ensures downstream methylation lookups
139        // align with the actual first emitted base.
140        #[expect(clippy::cast_possible_truncation, reason = "ref positions fit in u32")]
141        let hap_start = self.hap_position_for(ref_pos as u32);
142
143        while bases.len() < fragment_len && ref_pos < reference.len() {
144            // Check if the current reference position is a variant start.
145            if var_idx < overlapping_indices.len() {
146                let var = &self.variant_data[overlapping_indices[var_idx] as usize];
147                if var.ref_pos as usize == ref_pos {
148                    // Emit alt allele bases.
149                    for &b in &var.alt_bases {
150                        if bases.len() >= fragment_len {
151                            break;
152                        }
153                        bases.push(b);
154                        #[expect(
155                            clippy::cast_possible_truncation,
156                            reason = "ref positions fit in u32"
157                        )]
158                        ref_positions.push(ref_pos as u32);
159                    }
160                    // Skip over reference allele bases.
161                    ref_pos += var.ref_len as usize;
162                    var_idx += 1;
163                    continue;
164                }
165            }
166
167            // Emit reference base.
168            bases.push(reference[ref_pos]);
169            #[expect(clippy::cast_possible_truncation, reason = "ref positions fit in u32")]
170            ref_positions.push(ref_pos as u32);
171            ref_pos += 1;
172        }
173
174        // Truncate to exact fragment length (alt alleles may have added extra).
175        bases.truncate(fragment_len);
176        ref_positions.truncate(fragment_len);
177
178        (bases, ref_positions, hap_start)
179    }
180
181    /// Map a 0-based reference position to its 0-based haplotype position.
182    ///
183    /// Sums net length changes (`alt_len - ref_len`) across every variant
184    /// strictly upstream of `ref_pos` (i.e. `var.ref_pos + var.ref_len <=
185    /// ref_pos`). Variants that straddle `ref_pos` do not yet contribute —
186    /// the caller is presumed to be at a position outside any deletion's
187    /// interior (otherwise the haplotype position would be ambiguous).
188    ///
189    /// Used by [`Self::extract_fragment`] to compute `hap_start` and by
190    /// methylation-table construction to relate haplotype scans to per-
191    /// haplotype indices. Implementation is `O(log n)` via binary search
192    /// over the precomputed [`Self::end_prefix_deltas`] index.
193    #[must_use]
194    pub fn hap_position_for(&self, ref_pos: u32) -> u32 {
195        // partition_point returns the first index where the predicate is
196        // false; i.e. the first entry with `var_end > ref_pos`. Subtracting
197        // one gives the last entry with `var_end <= ref_pos`, whose
198        // cumulative delta is the answer.
199        let idx = self.end_prefix_deltas.partition_point(|&(end, _)| end <= ref_pos);
200        let delta = if idx == 0 { 0 } else { self.end_prefix_deltas[idx - 1].1 };
201        let hp = i64::from(ref_pos) + delta;
202        debug_assert!(hp >= 0, "hap_position_for produced negative position");
203        #[expect(clippy::cast_sign_loss, reason = "haplotype length is non-negative")]
204        #[expect(clippy::cast_possible_truncation, reason = "haplotype length fits in u32")]
205        let result = hp as u32;
206        result
207    }
208}
209
210/// Build haplotypes from a set of variant records for one contig.
211///
212/// For each allele index up to `max_ploidy`, constructs a [`Haplotype`]
213/// containing only the variants assigned to that allele.
214///
215/// Phased genotypes assign alleles deterministically. Unphased genotypes
216/// assign non-reference alleles to haplotypes using the provided RNG.
217///
218/// # Arguments
219/// * `variants` — Sorted variant records for this contig.
220/// * `max_ploidy` — Maximum ploidy across all variants (e.g. 2 for diploid).
221/// * `rng` — Random number generator for unphased genotype assignment.
222pub fn build_haplotypes(
223    variants: &[VariantRecord],
224    max_ploidy: usize,
225    rng: &mut impl rand::Rng,
226) -> Vec<Haplotype> {
227    // Collect variant data and tree intervals per haplotype.
228    let mut variant_data_per_hap: Vec<Vec<HaplotypeVariant>> =
229        (0..max_ploidy).map(|_| Vec::new()).collect();
230    let mut intervals_per_hap: Vec<Vec<Interval<u32>>> =
231        (0..max_ploidy).map(|_| Vec::new()).collect();
232
233    for vr in variants {
234        let gt = &vr.genotype;
235
236        // For unphased genotypes, generate a random permutation mapping
237        // allele indices to haplotype indices. This avoids artificial
238        // phasing of nearby variants while ensuring each allele goes to
239        // exactly one haplotype (unlike independent random draws, which
240        // would incorrectly place both alleles of a hom-alt on the same
241        // haplotype 25% of the time for diploid).
242        let hap_permutation: Vec<usize> = if gt.is_phased() {
243            (0..max_ploidy).collect()
244        } else {
245            let mut perm: Vec<usize> = (0..max_ploidy).collect();
246            // Fisher-Yates shuffle.
247            for i in (1..perm.len()).rev() {
248                let j = rng.random_range(0..=i);
249                perm.swap(i, j);
250            }
251            perm
252        };
253
254        for (allele_idx, allele) in gt.alleles().iter().enumerate() {
255            if allele_idx >= max_ploidy {
256                break;
257            }
258
259            let Some(allele_num) = allele else { continue };
260            if *allele_num == 0 {
261                continue;
262            }
263
264            let Some(alt_bases) = vr.allele_bases(*allele_num) else {
265                continue;
266            };
267
268            let hap_var = HaplotypeVariant {
269                ref_pos: vr.position,
270                #[expect(clippy::cast_possible_truncation, reason = "ref allele < 4 GB")]
271                ref_len: vr.ref_allele.len() as u32,
272                alt_bases: alt_bases.to_vec(),
273            };
274
275            let target_hap = hap_permutation[allele_idx];
276
277            // Store the variant and create a tree interval pointing to it.
278            let data_idx = variant_data_per_hap[target_hap].len();
279            variant_data_per_hap[target_hap].push(hap_var);
280
281            let end_pos = (vr.position as usize + vr.ref_allele.len()).saturating_sub(1);
282            #[expect(
283                clippy::cast_possible_wrap,
284                reason = "genomic coords and variant index < i32::MAX / u32::MAX"
285            )]
286            #[expect(
287                clippy::cast_possible_truncation,
288                reason = "genomic coords and variant index < i32::MAX / u32::MAX"
289            )]
290            let iv = Interval::new(vr.position as i32, end_pos as i32, data_idx as u32);
291            intervals_per_hap[target_hap].push(iv);
292        }
293    }
294
295    variant_data_per_hap
296        .into_iter()
297        .zip(intervals_per_hap)
298        .enumerate()
299        .map(|(i, (data, ivs))| {
300            let end_prefix_deltas = build_end_prefix_deltas(&data);
301            Haplotype {
302                allele_index: i,
303                variant_data: data,
304                variant_tree: COITree::new(&ivs),
305                end_prefix_deltas,
306            }
307        })
308        .collect()
309}
310
311/// Build the cumulative-delta index used by [`Haplotype::hap_position_for`].
312/// Sorts by variant end position so binary search can find the largest
313/// entry with `var_end <= ref_pos` for any query.
314#[expect(clippy::cast_possible_wrap, reason = "alt_bases.len() and ref_len fit in i64")]
315fn build_end_prefix_deltas(variants: &[HaplotypeVariant]) -> Vec<(u32, i64)> {
316    let mut entries: Vec<(u32, i64)> = variants
317        .iter()
318        .map(|v| {
319            let var_end = v.ref_pos + v.ref_len;
320            let delta = v.alt_bases.len() as i64 - i64::from(v.ref_len);
321            (var_end, delta)
322        })
323        .collect();
324    entries.sort_by_key(|&(end, _)| end);
325    let mut cum: i64 = 0;
326    entries
327        .into_iter()
328        .map(|(end, delta)| {
329            cum += delta;
330            (end, cum)
331        })
332        .collect()
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::vcf::genotype::Genotype;
339
340    /// Build a simple SNP variant record.
341    fn snp(pos: u32, ref_base: u8, alt_base: u8, gt: &str) -> VariantRecord {
342        VariantRecord {
343            position: pos,
344            ref_allele: vec![ref_base],
345            alt_alleles: vec![vec![alt_base]],
346            genotype: Genotype::parse(gt).unwrap(),
347        }
348    }
349
350    /// Build an indel variant record.
351    fn indel(pos: u32, ref_allele: &[u8], alt_allele: &[u8], gt: &str) -> VariantRecord {
352        VariantRecord {
353            position: pos,
354            ref_allele: ref_allele.to_vec(),
355            alt_alleles: vec![alt_allele.to_vec()],
356            genotype: Genotype::parse(gt).unwrap(),
357        }
358    }
359
360    #[test]
361    fn test_extract_fragment_no_variants() {
362        let reference = b"ACGTACGTACGT";
363        let haps = build_haplotypes(&[], 2, &mut rand::rng());
364        assert_eq!(haps.len(), 2);
365
366        let (bases, positions, hap_start) = haps[0].extract_fragment(reference, 2, 5);
367        assert_eq!(&bases, b"GTACG");
368        assert_eq!(&positions, &[2, 3, 4, 5, 6]);
369        assert_eq!(hap_start, 2);
370    }
371
372    #[test]
373    fn test_extract_fragment_with_snp() {
374        let reference = b"AAAAAAAA";
375        let variants = vec![snp(3, b'A', b'T', "0|1")];
376        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
377
378        // Haplotype 0 should have reference (allele 0).
379        let (bases, _, hs0) = haps[0].extract_fragment(reference, 0, 8);
380        assert_eq!(&bases, b"AAAAAAAA");
381        assert_eq!(hs0, 0);
382
383        // Haplotype 1 should have the SNP (allele 1).
384        let (bases, _, hs1) = haps[1].extract_fragment(reference, 0, 8);
385        assert_eq!(&bases, b"AAATAAAA");
386        assert_eq!(hs1, 0);
387    }
388
389    #[test]
390    fn test_extract_fragment_with_insertion() {
391        let reference = b"AAAAAAAA";
392        // Insertion: A -> ATT at position 3 (ref allele len 1, alt len 3).
393        let variants = vec![indel(3, b"A", b"ATT", "0|1")];
394        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
395
396        // Haplotype 1 has the insertion.
397        let (bases, positions, hap_start) = haps[1].extract_fragment(reference, 0, 10);
398        assert_eq!(&bases, b"AAAATTAAAA");
399        // Inserted bases all map back to the ref position of the variant (3).
400        assert_eq!(&positions, &[0, 1, 2, 3, 3, 3, 4, 5, 6, 7]);
401        // Fragment starts at ref 0, no upstream variants → hap_start == 0.
402        assert_eq!(hap_start, 0);
403    }
404
405    #[test]
406    fn test_extract_fragment_with_deletion() {
407        let reference = b"ACGTACGTAC";
408        // Deletion: ACG -> A at position 4. Replaces 3 ref bases (ACG at
409        // positions 4-6) with 1 alt base (A).
410        let variants = vec![indel(4, b"ACG", b"A", "0|1")];
411        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
412
413        let (bases, _, _) = haps[1].extract_fragment(reference, 0, 8);
414        assert_eq!(&bases, b"ACGTATAC");
415    }
416
417    #[test]
418    fn test_hom_alt_both_haplotypes_affected() {
419        let reference = b"AAAA";
420        let variants = vec![snp(1, b'A', b'T', "1|1")];
421        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
422
423        let (bases0, _, _) = haps[0].extract_fragment(reference, 0, 4);
424        let (bases1, _, _) = haps[1].extract_fragment(reference, 0, 4);
425        assert_eq!(&bases0, b"ATAA");
426        assert_eq!(&bases1, b"ATAA");
427    }
428
429    #[test]
430    fn test_phased_allele_assignment() {
431        let reference = b"AAAA";
432        // Phased 1|0: alt on haplotype 0, ref on haplotype 1.
433        let variants = vec![snp(1, b'A', b'T', "1|0")];
434        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
435
436        let (bases0, _, _) = haps[0].extract_fragment(reference, 0, 4);
437        let (bases1, _, _) = haps[1].extract_fragment(reference, 0, 4);
438        assert_eq!(&bases0, b"ATAA");
439        assert_eq!(&bases1, b"AAAA");
440    }
441
442    #[test]
443    fn test_fragment_starts_mid_reference() {
444        let reference = b"ACGTACGTAC";
445        let variants = vec![snp(5, b'C', b'T', "0|1")];
446        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
447
448        // Fragment starting at position 3, length 5: covers pos 3-7.
449        let (bases, positions, hap_start) = haps[1].extract_fragment(reference, 3, 5);
450        assert_eq!(&bases, b"TATGT");
451        assert_eq!(&positions, &[3, 4, 5, 6, 7]);
452        // SNP at pos 5 doesn't change net length, and the only variant
453        // straddles ref_start=3 → hap_start matches ref_start.
454        assert_eq!(hap_start, 3);
455    }
456
457    #[test]
458    fn test_fragment_starts_within_deletion() {
459        // Reference: ACGTACGTAC (positions 0-9)
460        // Deletion at pos 2: GTA (3 bases) -> G (1 base)
461        // After variant: AC + G + CGTAC = ACGCGTAC
462        let reference = b"ACGTACGTAC";
463        let variants = vec![indel(2, b"GTA", b"G", "0|1")];
464        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
465
466        // Fragment starting at position 3 (mid-deletion). The deletion
467        // consumes ref positions 2-4, so starting at 3 means we're inside
468        // the deletion. The pre-loop handler should skip past the deletion
469        // end (position 5) and continue from there.
470        let (bases, _, _) = haps[1].extract_fragment(reference, 3, 5);
471        assert_eq!(&bases, b"CGTAC");
472    }
473
474    #[test]
475    fn test_adjacent_variants() {
476        // Two adjacent SNPs with no reference gap between them.
477        let reference = b"AAAA";
478        let variants = vec![snp(1, b'A', b'T', "0|1"), snp(2, b'A', b'C', "0|1")];
479        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
480
481        let (bases, _, _) = haps[1].extract_fragment(reference, 0, 4);
482        assert_eq!(&bases, b"ATCA");
483    }
484
485    #[test]
486    fn test_variant_at_position_zero() {
487        let reference = b"ACGT";
488        let variants = vec![snp(0, b'A', b'T', "0|1")];
489        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
490
491        let (bases, _, _) = haps[1].extract_fragment(reference, 0, 4);
492        assert_eq!(&bases, b"TCGT");
493    }
494
495    #[test]
496    fn test_unphased_hom_alt_both_haplotypes() {
497        // Unphased hom-alt must place the alt on both haplotypes.
498        let reference = b"AAAA";
499        let variants = vec![snp(1, b'A', b'T', "1/1")];
500        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
501
502        let (bases0, _, _) = haps[0].extract_fragment(reference, 0, 4);
503        let (bases1, _, _) = haps[1].extract_fragment(reference, 0, 4);
504        assert_eq!(&bases0, b"ATAA");
505        assert_eq!(&bases1, b"ATAA");
506    }
507
508    #[test]
509    fn test_extract_fragment_starts_within_deletion_hap_start() {
510        // Reference: ACGTACGTAC (positions 0-9). Deletion at pos 2 spans
511        // 5 bases (REF=GTACG → ALT=G), so var_end = 7 and net delta = -4.
512        // A fragment starting at ref_start = 4 lands inside the deletion.
513        // The pre-loop advances ref_pos to 7, and the *first emitted base*
514        // is reference[7]. In haplotype coordinates that base lives at
515        // 7 + (-4) = 3, so `hap_start` must equal 3.
516        //
517        // Regression: prior code derived `hap_start` from the original
518        // `ref_start` (= 4 here), which doesn't account for the straddling
519        // deletion. That mis-aligned downstream methylation lookups for
520        // any fragment whose start fell inside a deletion span.
521        let reference = b"ACGTACGTAC";
522        let variants = vec![indel(2, b"GTACG", b"G", "0|1")];
523        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
524
525        let (bases, _, hap_start) = haps[1].extract_fragment(reference, 4, 3);
526        assert_eq!(&bases, b"TAC");
527        assert_eq!(hap_start, 3, "hap_start must reflect the post-skip ref_pos");
528    }
529
530    #[test]
531    fn test_hap_position_for_with_insertion() {
532        // Insertion adds 2 bases at ref pos 3 (A -> ATT, alt-ref = +2).
533        let variants = vec![indel(3, b"A", b"ATT", "0|1")];
534        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
535
536        // Position 3 itself is inside the variant span → no offset.
537        assert_eq!(haps[1].hap_position_for(3), 3);
538        // After the variant ends (var_end = 3 + 1 = 4), positions shift by +2.
539        assert_eq!(haps[1].hap_position_for(4), 6);
540        assert_eq!(haps[1].hap_position_for(7), 9);
541    }
542
543    #[test]
544    fn test_hap_position_for_with_deletion() {
545        // Deletion removes 2 bases at ref pos 4 (ACG -> A, alt-ref = -2).
546        let variants = vec![indel(4, b"ACG", b"A", "0|1")];
547        let haps = build_haplotypes(&variants, 2, &mut rand::rng());
548
549        // Positions before the deletion end: no offset.
550        assert_eq!(haps[1].hap_position_for(4), 4);
551        assert_eq!(haps[1].hap_position_for(6), 6);
552        // After the deletion ends (var_end = 4 + 3 = 7), positions shift by -2.
553        assert_eq!(haps[1].hap_position_for(7), 5);
554        assert_eq!(haps[1].hap_position_for(9), 7);
555    }
556}