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}