holodeck_lib/vcf/methylation.rs
1//! VCF MT/MB FORMAT-field schema, per-CpG ownership classifier, and
2//! reader/writer for methylation-annotated VCFs.
3//!
4//! Each CpG on each haplotype is routed to exactly one VCF record by
5//! [`classify_cpgs`]: methylation-only records for reference-coordinate
6//! CpGs outside any variant span, and variant-record annotations for CpGs
7//! inside or straddling a variant's alt allele.
8
9use rand::SeedableRng;
10
11use crate::haplotype::build_haplotypes;
12use crate::vcf::genotype::VariantRecord;
13
14/// Error returned by [`classify_cpgs`] when the input violates the
15/// classifier's preconditions: phased genotypes only, and no two variants
16/// whose REF spans overlap while both alt alleles land on a shared
17/// haplotype.
18#[derive(Debug, thiserror::Error)]
19pub enum ClassifyError {
20 /// A variant record has an unphased GT. Methylation truth is
21 /// haplotype-specific and meaningless without phasing.
22 #[error("methylation requires phased genotypes; variant at position {pos} has unphased GT")]
23 UnphasedGenotype {
24 /// 0-based reference position of the offending variant.
25 pos: u32,
26 },
27 /// Two variant records have overlapping REF spans and at least one
28 /// haplotype carries the alt allele at both sites, which would
29 /// materialize incompatible alt spans on that haplotype. Phased
30 /// overlaps on disjoint haplotypes (e.g. `1|0` + `0|1`) are accepted.
31 #[error(
32 "variants at positions {a_pos} and {b_pos} have overlapping REF spans on a shared haplotype"
33 )]
34 OverlappingVariants {
35 /// 0-based reference position of the first (upstream) variant.
36 a_pos: u32,
37 /// 0-based reference position of the second (downstream) variant.
38 b_pos: u32,
39 },
40}
41
42/// Where a single CpG (haplotype-coord pair) is recorded in the
43/// methylated VCF.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum CpgPlacement {
46 /// Stored as a standalone methylation-only record (REF=C, ALT='.')
47 /// at the given reference coordinate of the top-strand C.
48 Standalone { ref_pos: u32 },
49 /// Stored on the variant record at the given index in the variants
50 /// slice, at the given offset within that haplotype's alt allele.
51 OnVariant { variant_index: usize, hap_offset: u32 },
52}
53
54/// Classify every CpG on every haplotype into [`CpgPlacement`]s.
55///
56/// Walks every haplotype's materialized sequence, attributes each CpG
57/// dinucleotide to either a standalone methylation record (both bases come
58/// from reference on at least one haplotype) or to a variant record (at
59/// least one base of the pair comes from an alt allele). For CpGs where
60/// the two bases come from different variants, the upstream variant (lower
61/// reference position) owns the placement.
62///
63/// `reference` is the contig's reference sequence (uppercase). `variants`
64/// are the sorted variant records for the contig.
65///
66/// # Errors
67///
68/// Returns [`ClassifyError::UnphasedGenotype`] if any variant record has an
69/// unphased GT field. Returns [`ClassifyError::OverlappingVariants`] if any
70/// two adjacent variant records have overlapping REF spans and at least one
71/// haplotype carries the alt allele at both sites; phased overlaps on
72/// disjoint haplotypes are accepted.
73pub fn classify_cpgs(
74 reference: &[u8],
75 variants: &[VariantRecord],
76) -> Result<Vec<CpgPlacement>, ClassifyError> {
77 // Single-contig public API: derive ploidy from the supplied variants.
78 // Multi-contig callers should use `resolve_sample_ploidy` over the full
79 // VCF and pass the result to `write_contig` / `read_contig_methylation`
80 // so variant-free contigs don't fall back to a default ploidy that
81 // disagrees with the rest of the sample.
82 let sample_ploidy = variants.iter().map(|v| v.genotype.ploidy()).max().unwrap_or(2);
83 let haplotypes = build_methylation_haplotypes(variants, sample_ploidy);
84 classify_cpgs_with_haplotypes(reference, variants, &haplotypes)
85}
86
87/// Build the canonical haplotype layout used everywhere methylation truth is
88/// (de)serialized — `seed = 0`, max ploidy derived from `variants`. Writer
89/// (`write_contig`) and reader (`read_contig_methylation`) must agree on
90/// per-haplotype ordering or MT/MB bits land on the wrong indices, and
91/// since `classify_cpgs` rejects unphased genotypes upfront, every layout
92/// reaching this helper is deterministic regardless of seed. Centralizing
93/// the construction here lets one contig produce a single
94/// `Vec<Haplotype>` that all downstream methylation helpers borrow, instead
95/// of each call site rebuilding it from scratch with its own
96/// `SmallRng::seed_from_u64(0)`.
97pub(crate) fn build_methylation_haplotypes(
98 variants: &[VariantRecord],
99 sample_ploidy: usize,
100) -> Vec<crate::haplotype::Haplotype> {
101 let mut rng = rand::rngs::SmallRng::seed_from_u64(0);
102 build_haplotypes(variants, sample_ploidy, &mut rng)
103}
104
105/// Inner classifier used by [`write_contig`] when haplotypes are already
106/// materialized for the same contig. Skips the redundant `build_haplotypes`
107/// the public [`classify_cpgs`] entry point does for callers that don't
108/// have a haplotype slice in hand. Behavior is otherwise identical and
109/// honors the same `(seed=0, phased-only)` contract.
110fn classify_cpgs_with_haplotypes(
111 reference: &[u8],
112 variants: &[VariantRecord],
113 haplotypes: &[crate::haplotype::Haplotype],
114) -> Result<Vec<CpgPlacement>, ClassifyError> {
115 // Reject unphased GTs. Methylation truth is haplotype-specific and
116 // requires a definite allele assignment for each haplotype.
117 for v in variants {
118 if !v.genotype.is_phased() {
119 return Err(ClassifyError::UnphasedGenotype { pos: v.position });
120 }
121 }
122
123 check_overlaps_on_shared_haplotypes(variants)?;
124
125 // Fast path: no variants → every haplotype is the reference; scan once.
126 // This is O(L) with zero allocations; the haplotype-aware path below
127 // allocates per-variant range tables and materializes each haplotype.
128 if variants.is_empty() {
129 return Ok(crate::meth::find_reference_cpgs(reference)
130 .into_iter()
131 .map(|ref_pos| CpgPlacement::Standalone { ref_pos })
132 .collect());
133 }
134
135 let mut placements: Vec<CpgPlacement> = Vec::new();
136
137 for haplotype in haplotypes {
138 // Materialize the full haplotype sequence.
139 #[expect(clippy::cast_possible_truncation, reason = "reference length fits in u32")]
140 let cap = haplotype.hap_position_for(reference.len() as u32) as usize;
141 let (hap_bases, ref_positions, _hap_start) = haplotype.extract_fragment(reference, 0, cap);
142 let len = hap_bases.len();
143 if len < 2 {
144 continue;
145 }
146
147 // Build per-variant hap-coord ranges for variants this haplotype carries.
148 // Each entry: (variant_index_in_variants_slice, hap_start, hap_end_exclusive)
149 // where hap_end = hap_start + alt_bases.len().
150 //
151 // A haplotype carries a variant when its allele index for that variant is
152 // non-zero (alt). We use `haplotype.hap_position_for(v.position)` to map
153 // the variant's reference position to haplotype coordinates.
154 let hap_allele_index = haplotype.allele_index();
155 // Precondition: no two variants on the same haplotype share a reference
156 // position (overlapping variants). Enforced upfront in classify_cpgs's
157 // validation pass (returns ClassifyError::OverlappingVariants).
158 let mut var_hap_ranges: Vec<(usize, u32, u32)> = Vec::with_capacity(variants.len());
159 for (vi, v) in variants.iter().enumerate() {
160 // Get this haplotype's allele at this variant site. Alleles are
161 // ordered by allele_index within the genotype. For a phased diploid
162 // "1|0", allele_index 0 has allele Some(1), allele_index 1 has Some(0).
163 let allele_num = v.genotype.alleles().get(hap_allele_index).copied().flatten();
164 let Some(allele_num) = allele_num else { continue };
165 if allele_num == 0 {
166 // This haplotype carries the reference allele at this site.
167 continue;
168 }
169 let Some(alt_bases) = v.allele_bases(allele_num) else { continue };
170 let hap_start = haplotype.hap_position_for(v.position);
171 #[expect(clippy::cast_possible_truncation, reason = "alt allele len fits u32")]
172 let hap_end = hap_start + alt_bases.len() as u32;
173 var_hap_ranges.push((vi, hap_start, hap_end));
174 }
175
176 // Scan materialized haplotype for CpG dinucleotides.
177 for h in 0..len - 1 {
178 let c0 = hap_bases[h].to_ascii_uppercase();
179 let c1 = hap_bases[h + 1].to_ascii_uppercase();
180 if c0 != b'C' || c1 != b'G' {
181 continue;
182 }
183
184 // Cast the loop index to u32 once. Haplotype lengths are bounded
185 // by reference length + net indel size, both of which fit in u32.
186 #[expect(clippy::cast_possible_truncation, reason = "haplotype length fits in u32")]
187 let hpos = h as u32;
188
189 // Determine source of each base: variant or reference.
190 let src_h = base_source(hpos, &var_hap_ranges);
191 let src_h1 = base_source(hpos + 1, &var_hap_ranges);
192
193 let placement = match (src_h, src_h1) {
194 // Both bases come from reference → standalone at the ref
195 // position of the top-strand C.
196 (BaseSource::Reference, BaseSource::Reference) => {
197 CpgPlacement::Standalone { ref_pos: ref_positions[h] }
198 }
199
200 // C is from a variant, G is from reference → the variant owns.
201 (BaseSource::Variant { variant_index, hap_start }, BaseSource::Reference) => {
202 let hap_offset = hpos - hap_start;
203 CpgPlacement::OnVariant { variant_index, hap_offset }
204 }
205
206 // C is from reference, G is from a variant → the variant owns.
207 (BaseSource::Reference, BaseSource::Variant { variant_index, hap_start }) => {
208 let hap_offset = (hpos + 1) - hap_start;
209 CpgPlacement::OnVariant { variant_index, hap_offset }
210 }
211
212 // Both bases from the same variant → that variant owns.
213 (
214 BaseSource::Variant { variant_index: vi0, hap_start: hs0 },
215 BaseSource::Variant { variant_index: vi1, hap_start: _ },
216 ) if vi0 == vi1 => {
217 let hap_offset = hpos - hs0;
218 CpgPlacement::OnVariant { variant_index: vi0, hap_offset }
219 }
220
221 // Both bases from different variants → upstream wins (lower
222 // reference position), which is the variant with the smaller
223 // index because variants are sorted by position.
224 (
225 BaseSource::Variant { variant_index: vi0, hap_start: hs0 },
226 BaseSource::Variant { variant_index: vi1, hap_start: hs1 },
227 ) => {
228 // variants slice is sorted by position; smaller index ⟹ upstream.
229 // vi0 == vi1 is handled by the same-variant arm above.
230 if vi0 < vi1 {
231 let hap_offset = hpos - hs0;
232 CpgPlacement::OnVariant { variant_index: vi0, hap_offset }
233 } else {
234 let hap_offset = (hpos + 1) - hs1;
235 CpgPlacement::OnVariant { variant_index: vi1, hap_offset }
236 }
237 }
238 };
239
240 placements.push(placement);
241 }
242 }
243
244 // Deduplicate and sort. Sort key: (effective_ref_pos, discriminant, hap_offset).
245 // Standalone sorts by ref_pos; OnVariant sorts by variants[vi].position
246 // then hap_offset, so placements interleave in reference order.
247 // Discriminant 0 (Standalone) < 1 (OnVariant) breaks ties at the same position.
248 placements.sort_unstable_by_key(|p| sort_key(p, variants));
249 placements.dedup();
250 Ok(placements)
251}
252
253/// Reject overlapping REF spans only when the two variants can coexist on
254/// the same haplotype. Phased records like `1|0` + `0|1` can overlap in
255/// reference coordinates without ever materializing both alt spans on one
256/// haplotype, and the haplotype builder handles them correctly. The
257/// genuine conflict is one haplotype carrying both alt alleles.
258///
259/// Variants are assumed sorted by position (current API contract — see
260/// [`crate::vcf::parse_variants_by_contig`]). For each haplotype, this
261/// tracks the furthest ALT-span end seen so far; a new variant whose
262/// haplotype is ALT and whose start falls before that end is a genuine
263/// overlap. Tracking per-haplotype catches non-adjacent overlaps where an
264/// intervening variant on a different haplotype hides a long upstream
265/// REF span from a simple adjacent-pair scan.
266fn check_overlaps_on_shared_haplotypes(variants: &[VariantRecord]) -> Result<(), ClassifyError> {
267 let max_ploidy = variants.iter().map(|v| v.genotype.ploidy()).max().unwrap_or(0);
268 // Per haplotype: (end_exclusive, start_pos) of the most recent ALT span.
269 let mut last_alt_span: Vec<Option<(u32, u32)>> = vec![None; max_ploidy];
270
271 for v in variants {
272 #[expect(clippy::cast_possible_truncation, reason = "ref allele len fits u32")]
273 let v_end = v.position + v.ref_allele.len() as u32;
274 for (hi, slot) in last_alt_span.iter_mut().enumerate() {
275 let carries_alt =
276 v.genotype.alleles().get(hi).copied().flatten().is_some_and(|x| x != 0);
277 if !carries_alt {
278 continue;
279 }
280 if let Some((prev_end, prev_pos)) = *slot
281 && prev_end > v.position
282 {
283 return Err(ClassifyError::OverlappingVariants {
284 a_pos: prev_pos,
285 b_pos: v.position,
286 });
287 }
288 *slot = Some((v_end, v.position));
289 }
290 }
291 Ok(())
292}
293
294/// The source of a single base at haplotype coordinate `h`.
295#[derive(Debug, Clone, Copy)]
296enum BaseSource {
297 /// Base comes from the reference. The reference position is carried by
298 /// [`ref_positions`] in the caller, not stored in this variant.
299 Reference,
300 /// Base comes from variant `variant_index`'s alt allele. `hap_start` is
301 /// the haplotype-coordinate start of that variant's alt span.
302 Variant { variant_index: usize, hap_start: u32 },
303}
304
305/// Determine whether haplotype coordinate `h` falls inside any variant's
306/// alt-allele span. Returns `BaseSource::Variant` for the first matching
307/// range, or `BaseSource::Reference` if no variant spans `h`.
308fn base_source(h: u32, var_hap_ranges: &[(usize, u32, u32)]) -> BaseSource {
309 for &(vi, hap_start, hap_end) in var_hap_ranges {
310 if h >= hap_start && h < hap_end {
311 return BaseSource::Variant { variant_index: vi, hap_start };
312 }
313 }
314 BaseSource::Reference
315}
316
317/// Compute a sort key for a [`CpgPlacement`] so that placements are ordered
318/// by their effective reference position. Standalone records use their own
319/// `ref_pos`; `OnVariant` records use the variant's reference position as the
320/// primary key and `hap_offset` as the tertiary key.
321///
322/// The discriminant (second tuple element) is 0 for `Standalone` and 1 for
323/// `OnVariant`, so when both appear at the same reference position the
324/// reference-coordinate methylation sorts before the variant-allele methylation.
325fn sort_key(placement: &CpgPlacement, variants: &[VariantRecord]) -> (u32, u8, u32) {
326 match placement {
327 CpgPlacement::Standalone { ref_pos } => (*ref_pos, 0, 0),
328 CpgPlacement::OnVariant { variant_index, hap_offset } => {
329 (variants[*variant_index].position, 1, *hap_offset)
330 }
331 }
332}
333
334/// Default sample column name used by `methylate` when no VCF sample is provided.
335pub const DEFAULT_METHYLATE_SAMPLE: &str = "METHYLATE";
336
337/// Write a VCF header for a methylation-annotated VCF to `writer`.
338///
339/// The header includes:
340/// - `##fileformat=VCFv4.4`
341/// - `##holodeckVersion=<version>`
342/// - `##holodeckCommand=<command_line>`
343/// - `##FORMAT` lines for GT, MT, and MB
344/// - One `##contig` line per entry in `dict`
345/// - The `#CHROM` column header with one sample column
346///
347/// `sample` is the sample name written in the column header. If `None`,
348/// [`DEFAULT_METHYLATE_SAMPLE`] is used.
349///
350/// # Errors
351///
352/// Returns an error if any write to `writer` fails.
353pub fn write_vcf_header<W: std::io::Write>(
354 writer: &mut W,
355 dict: &crate::sequence_dict::SequenceDictionary,
356 sample: Option<&str>,
357 version: &str,
358 command_line: &str,
359) -> anyhow::Result<()> {
360 let sample_name = sample.unwrap_or(DEFAULT_METHYLATE_SAMPLE);
361 writeln!(writer, "##fileformat=VCFv4.4")?;
362 writeln!(writer, "##holodeckVersion={version}")?;
363 writeln!(writer, "##holodeckCommand={command_line}")?;
364 writeln!(writer, "##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">")?;
365 writeln!(
366 writer,
367 "##FORMAT=<ID=MT,Number=.,Type=String,\
368 Description=\"Methylation state, top strand. \
369 Per-haplotype pipe-separated bitstring: 1=methylated, \
370 0=unmethylated, .=haplotype carries REF or no owned CpG.\">"
371 )?;
372 writeln!(
373 writer,
374 "##FORMAT=<ID=MB,Number=.,Type=String,\
375 Description=\"Methylation state, bottom strand. \
376 Per-haplotype pipe-separated bitstring: 1=methylated, \
377 0=unmethylated, .=haplotype carries REF or no owned CpG.\">"
378 )?;
379 for seq in dict.iter() {
380 writeln!(writer, "##contig=<ID={},length={}>", seq.name(), seq.length())?;
381 }
382 writeln!(writer, "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t{sample_name}")?;
383 Ok(())
384}
385
386/// Discriminated union for `write_contig`'s internal emit list.
387///
388/// Carries either the 0-based reference position of a standalone CpG record
389/// or the index into the variants slice for a variant record. Used as the
390/// payload of the `(pos_1based, discriminant, RecordKind)` tuples that
391/// `write_contig` sorts before emitting.
392enum WriteKind {
393 /// Case-1 methylation-only record. Payload is the 0-based reference
394 /// position of the top-strand C.
395 Standalone(u32),
396 /// Case-2 variant record. Payload is the variant's index in the variants
397 /// slice.
398 Variant(usize),
399}
400
401/// Write all methylation-only records for one contig as VCF text rows.
402///
403/// Each row has the form:
404/// - Standalone (Case 1, REF=C ALT=.):
405/// `<chrom>\t<POS>\t.\tC\t.\t.\t.\t.\tMT:MB\t<mt>:<mb>`
406/// - Variant (Case 2, with GT):
407/// `<chrom>\t<POS>\t.\t<REF>\t<ALT>\t.\t.\t.\tGT:MT:MB\t<gt>:<mt>:<mb>`
408///
409/// `POS` is 1-based. `<mt>`/`<mb>` are `|`-separated per-haplotype bitstrings:
410/// `"1"` = methylated CpG, `"0"` = unmethylated CpG, `"."` = haplotype
411/// carries REF or no CpGs in the alt allele. For standalone records the
412/// per-haplotype value is always a single bit (`"0"` or `"1"`).
413///
414/// Standalone and variant records are interleaved in ascending reference
415/// position order. Header writing is the caller's responsibility.
416///
417/// # Errors
418///
419/// Returns an error if any variant has an unphased genotype, two variants
420/// have overlapping REF spans (see [`classify_cpgs`]), or any write to
421/// `writer` fails.
422pub fn write_contig<W: std::io::Write>(
423 writer: &mut W,
424 chrom: &str,
425 reference: &[u8],
426 variants: &[VariantRecord],
427 methylation: &crate::meth::ContigMethylation,
428 sample_ploidy: usize,
429) -> anyhow::Result<()> {
430 // One canonical haplotype build per contig — see
431 // [`build_methylation_haplotypes`]. The classifier, the per-variant
432 // CpG-offset table, and the per-haplotype bit-string emitters below
433 // all borrow this same slice instead of rebuilding it from scratch
434 // with their own `SmallRng::seed_from_u64(0)`. `sample_ploidy` (passed
435 // from the caller's whole-VCF resolution) sizes the haplotype slice
436 // even on variant-free contigs, so haploid/triploid samples don't
437 // silently get diploid MT/MB shapes here.
438 let haplotypes = build_methylation_haplotypes(variants, sample_ploidy);
439
440 let placements = classify_cpgs_with_haplotypes(reference, variants, &haplotypes)?;
441
442 // Pre-compute per-variant, per-haplotype absolute CpG hap-coords so we can
443 // look them up when writing variant records.
444 let var_hap_coords =
445 per_variant_per_hap_cpg_offsets_with_haplotypes(reference, variants, &haplotypes);
446
447 // Step 1: Build sorted emit list.
448 //
449 // Build the full interleaved emit order: variant rows and standalone CpG
450 // rows, sorted by their 1-based VCF POS. Ties are broken by discriminant:
451 // standalone (0) before variant (1) — matching classify_cpgs's sort_key.
452 //
453 // All variants get a row in the output (even those with no owned CpGs),
454 // so that simulate can read the GT from every variant's record.
455 let mut records: Vec<(u32, u8, WriteKind)> = Vec::new();
456
457 // Collect standalone CpG positions (deduplicated by classify_cpgs already)
458 // and the first occurrence of each variant index from OnVariant placements.
459 let mut seen_variant: std::collections::HashSet<usize> = std::collections::HashSet::new();
460 for placement in &placements {
461 match placement {
462 CpgPlacement::Standalone { ref_pos } => {
463 records.push((*ref_pos + 1, 0, WriteKind::Standalone(*ref_pos)));
464 }
465 CpgPlacement::OnVariant { variant_index, .. } => {
466 if seen_variant.insert(*variant_index) {
467 let pos_1based = variants[*variant_index].position + 1;
468 records.push((pos_1based, 1, WriteKind::Variant(*variant_index)));
469 }
470 }
471 }
472 }
473 // Add any variants that have no owned CpGs (zero OnVariant placements).
474 // These still need a row in the output for simulate to read the GT.
475 for (vi, v) in variants.iter().enumerate() {
476 if !seen_variant.contains(&vi) {
477 let pos_1based = v.position + 1;
478 records.push((pos_1based, 1, WriteKind::Variant(vi)));
479 }
480 }
481 // Sort by (1-based POS, discriminant): standalone (0) before variant (1).
482 records.sort_unstable_by_key(|(pos, disc, _)| (*pos, *disc));
483
484 // Step 2: Emit each record.
485 for (pos_1based, _, kind) in &records {
486 match kind {
487 WriteKind::Standalone(ref_pos) => {
488 let mt = per_hap_top_bit_string(methylation, &haplotypes, *ref_pos);
489 let mb = per_hap_bot_bit_string(methylation, &haplotypes, *ref_pos);
490 writeln!(writer, "{chrom}\t{pos_1based}\t.\tC\t.\t.\t.\t.\tMT:MB\t{mt}:{mb}")?;
491 }
492 WriteKind::Variant(vi) => {
493 let v = &variants[*vi];
494 let ref_str = std::str::from_utf8(&v.ref_allele).unwrap_or("N");
495 let alt_str = v
496 .alt_alleles
497 .iter()
498 .map(|a| std::str::from_utf8(a).unwrap_or("N"))
499 .collect::<Vec<_>>()
500 .join(",");
501 let gt_str = format_genotype(&v.genotype);
502 // Per-haplotype absolute hap-coords for this variant
503 // (hap_coords[variant_index][hap_idx] → sorted absolute hap coords).
504 let hap_coords_for_var = &var_hap_coords[*vi];
505 let top_meth = format_variant_meth_field(v, hap_coords_for_var, methylation, false);
506 let bot_meth = format_variant_meth_field(v, hap_coords_for_var, methylation, true);
507 writeln!(
508 writer,
509 "{chrom}\t{pos_1based}\t.\t{ref_str}\t{alt_str}\t.\t.\t.\tGT:MT:MB\t{gt_str}:{top_meth}:{bot_meth}"
510 )?;
511 }
512 }
513 }
514 Ok(())
515}
516
517/// Composite identity of one variant row in the methylation VCF — the
518/// quadruple `(POS_1based, REF, ALT-list, GT)`. Used as the key in the
519/// writer-side index that [`read_contig_methylation`] consults when decoding
520/// MT/MB rows: keying by POS alone collapses multi-allelic / decomposed sites
521/// at the same position and routes downstream rows through the wrong
522/// `VariantRecord`. Strings here mirror exactly what [`write_contig`] emits
523/// in cols 4 (REF), 5 (ALT) and the GT subfield of col 10, so writer and
524/// reader hash to the same key without any normalization step.
525#[derive(Debug, Clone, Eq, PartialEq, Hash)]
526struct VariantKey {
527 /// 1-based VCF POS.
528 pos_1based: u32,
529 /// REF allele as written in column 4.
530 ref_allele: String,
531 /// ALT allele list as written in column 5 (comma-joined for multi-allelic).
532 alt_alleles: String,
533 /// Phased GT string as written by [`format_genotype`] (e.g. `1|0`).
534 gt: String,
535}
536
537/// Build a [`VariantKey`] for a [`VariantRecord`] using the exact same
538/// string conversions [`write_contig`] applies when emitting the row.
539/// Centralizing this keeps the writer's index construction and the writer's
540/// row formatting in lock-step.
541fn variant_key_from_record(v: &VariantRecord) -> VariantKey {
542 let ref_allele = std::str::from_utf8(&v.ref_allele).unwrap_or("N").to_string();
543 let alt_alleles = v
544 .alt_alleles
545 .iter()
546 .map(|a| std::str::from_utf8(a).unwrap_or("N"))
547 .collect::<Vec<_>>()
548 .join(",");
549 VariantKey {
550 pos_1based: v.position + 1,
551 ref_allele,
552 alt_alleles,
553 gt: format_genotype(&v.genotype),
554 }
555}
556
557/// Format a [`Genotype`] as a phased `allele1|allele2|...` string.
558/// Missing alleles (`.`) are represented as `"."`. All GTs in the methylation
559/// VCF are phased (enforced by [`classify_cpgs`]), so `|` is always used.
560fn format_genotype(gt: &crate::vcf::genotype::Genotype) -> String {
561 gt.alleles()
562 .iter()
563 .map(|a| match a {
564 Some(idx) => idx.to_string(),
565 None => ".".to_string(),
566 })
567 .collect::<Vec<_>>()
568 .join("|")
569}
570
571/// For variant record `v` at index `vi`, return the per-haplotype `MT` or
572/// `MB` field value as a `|`-separated string. Each haplotype's entry is:
573/// - `"."` if it carries REF (allele 0) or has a missing allele
574/// - A bitstring of `'0'`/`'1'` characters (one per owned CpG, 5'→3' order)
575/// if it carries ALT, or `"."` if the alt has no owned CpGs
576///
577/// `hap_coords_for_var` is indexed by haplotype; each entry is the sorted
578/// list of **absolute** hap-coords of owned top-C positions for this variant
579/// on that haplotype (see [`per_variant_per_hap_cpg_offsets`]).
580///
581/// `bottom_strand` selects which bitmap to read: `false` = top (MT),
582/// `true` = bottom (MB). Query top-strand at `hap_coord`, bottom-strand at
583/// `hap_coord + 1`.
584fn format_variant_meth_field(
585 v: &VariantRecord,
586 hap_coords_for_var: &[Vec<u32>],
587 methylation: &crate::meth::ContigMethylation,
588 bottom_strand: bool,
589) -> String {
590 (0..methylation.len())
591 .map(|hi| {
592 // Determine this haplotype's allele at this variant.
593 let allele = v.genotype.alleles().get(hi).copied().flatten();
594 match allele {
595 None | Some(0) => ".".to_string(), // REF or missing → no methylation info
596 Some(_) => {
597 // ALT haplotype — read methylation bits at owned CpG hap-coords.
598 let hap_coords = if hi < hap_coords_for_var.len() {
599 hap_coords_for_var[hi].as_slice()
600 } else {
601 &[]
602 };
603 if hap_coords.is_empty() {
604 // No CpGs owned by this variant on this haplotype.
605 ".".to_string()
606 } else {
607 let table = methylation.table_for(hi);
608 hap_coords
609 .iter()
610 .map(|&hap_coord| {
611 // hap_coord is the absolute hap-coord of the top-C;
612 // query top-strand at hap_coord, bottom-strand at hap_coord + 1.
613 let query_coord =
614 if bottom_strand { hap_coord + 1 } else { hap_coord };
615 if table.is_methylated(query_coord, bottom_strand) {
616 '1'
617 } else {
618 '0'
619 }
620 })
621 .collect()
622 }
623 }
624 }
625 })
626 .collect::<Vec<_>>()
627 .join("|")
628}
629
630/// For each (variant_index, haplotype_index) pair, return the sorted list of
631/// **absolute** haplotype-coordinate positions where a CpG's top-C lives in
632/// that haplotype's alt allele at that variant.
633///
634/// **Encoding note:** these are absolute hap-coords, NOT offsets relative to
635/// the variant's `hap_start`. This differs from [`classify_cpgs`]'s
636/// `OnVariant { hap_offset }` payload, which uses relative offsets. The
637/// absolute encoding is used here so the writer can look up methylation
638/// bits via `MethylationTable::is_methylated(hap_coord, ...)` directly,
639/// without re-computing `hap_position_for` per CpG.
640///
641/// Returns `hap_coords[variant_index][hap_index]` = sorted `Vec<u32>` of
642/// absolute hap-coord top-C positions owned by that variant on that haplotype
643/// (i.e., CpGs where the upstream-wins rule attributes them to `variant_index`).
644/// Haplotypes carrying REF (allele 0) or missing alleles have empty lists.
645///
646/// Mirrors the inner loop of [`classify_cpgs`] but tracks per-haplotype
647/// attribution rather than producing a deduplicated placement list. Takes a
648/// pre-built `&[Haplotype]` slice from [`build_methylation_haplotypes`] —
649/// callers share that slice with [`classify_cpgs_with_haplotypes`] and the
650/// writer's per-haplotype bit-string helpers so the `(seed=0)` haplotype
651/// layout is materialized exactly once per contig.
652fn per_variant_per_hap_cpg_offsets_with_haplotypes(
653 reference: &[u8],
654 variants: &[VariantRecord],
655 haplotypes: &[crate::haplotype::Haplotype],
656) -> Vec<Vec<Vec<u32>>> {
657 if variants.is_empty() {
658 return Vec::new();
659 }
660
661 // Result: outer indexed by variant_index, inner by hap_allele_index.
662 let n_vars = variants.len();
663 let n_haps = haplotypes.len();
664 // hap_coords[vi][hi] = sorted Vec<u32> of absolute hap coords of owned top-C
665 let mut hap_coords: Vec<Vec<Vec<u32>>> = vec![vec![Vec::new(); n_haps]; n_vars];
666
667 for haplotype in haplotypes {
668 let hi = haplotype.allele_index(); // haplotype's index in the allele order
669
670 #[expect(clippy::cast_possible_truncation, reason = "reference length fits in u32")]
671 let cap = haplotype.hap_position_for(reference.len() as u32) as usize;
672 let (hap_bases, _ref_positions, _hap_start) = haplotype.extract_fragment(reference, 0, cap);
673 let len = hap_bases.len();
674 if len < 2 {
675 continue;
676 }
677
678 // Build per-variant hap-coord ranges for variants this haplotype carries.
679 let mut var_hap_ranges: Vec<(usize, u32, u32)> = Vec::with_capacity(variants.len());
680 for (vi, v) in variants.iter().enumerate() {
681 let allele_num = v.genotype.alleles().get(hi).copied().flatten();
682 let Some(allele_num) = allele_num else { continue };
683 if allele_num == 0 {
684 continue;
685 }
686 let Some(alt_bases) = v.allele_bases(allele_num) else { continue };
687 let hap_start = haplotype.hap_position_for(v.position);
688 #[expect(clippy::cast_possible_truncation, reason = "alt allele len fits u32")]
689 let hap_end = hap_start + alt_bases.len() as u32;
690 var_hap_ranges.push((vi, hap_start, hap_end));
691 }
692
693 // Scan for CpG dinucleotides and assign them to the owning variant
694 // using the same upstream-wins rule as classify_cpgs.
695 for h in 0..len - 1 {
696 let c0 = hap_bases[h].to_ascii_uppercase();
697 let c1 = hap_bases[h + 1].to_ascii_uppercase();
698 if c0 != b'C' || c1 != b'G' {
699 continue;
700 }
701
702 #[expect(clippy::cast_possible_truncation, reason = "haplotype length fits in u32")]
703 let hpos = h as u32;
704
705 let src_h = base_source(hpos, &var_hap_ranges);
706 let src_h1 = base_source(hpos + 1, &var_hap_ranges);
707
708 // Determine the owning variant index using the same upstream-wins
709 // rule as classify_cpgs. None means both bases are from reference
710 // (standalone CpG — skip here, handled by write_contig directly).
711 let owning_vi: Option<usize> = match (src_h, src_h1) {
712 (BaseSource::Reference, BaseSource::Reference) => None,
713 (BaseSource::Variant { variant_index, .. }, BaseSource::Reference)
714 | (BaseSource::Reference, BaseSource::Variant { variant_index, .. }) => {
715 Some(variant_index)
716 }
717 (
718 BaseSource::Variant { variant_index: vi0, .. },
719 BaseSource::Variant { variant_index: vi1, .. },
720 ) if vi0 == vi1 => Some(vi0),
721 (
722 BaseSource::Variant { variant_index: vi0, .. },
723 BaseSource::Variant { variant_index: vi1, .. },
724 ) => Some(vi0.min(vi1)), // upstream wins = smaller index
725 };
726
727 if let Some(vi) = owning_vi
728 && vi < n_vars
729 && hi < n_haps
730 {
731 hap_coords[vi][hi].push(hpos); // absolute hap coord of top-C
732 }
733 }
734 }
735
736 // Sort each inner vec (they're appended in scan order which is already
737 // ascending, but sort for correctness guarantee).
738 for var_hap_coords in &mut hap_coords {
739 for hc in var_hap_coords.iter_mut() {
740 hc.sort_unstable();
741 }
742 }
743
744 hap_coords
745}
746
747/// Error returned by [`read_contig_methylation`] when the input VCF stream
748/// violates the writer's encoding contract.
749#[derive(Debug, thiserror::Error)]
750pub enum ReadError {
751 /// A record's `MT`/`MB` bitstring length disagrees with the expected
752 /// number of CpGs for that haplotype's alt allele.
753 #[error("variant at pos {pos} hap {hap}: MT/MB length {actual} != expected {expected}")]
754 MtMbLengthMismatch {
755 /// 1-based VCF POS of the offending variant.
756 pos: u32,
757 /// Zero-based haplotype index.
758 hap: usize,
759 /// Length of the parsed bitstring.
760 actual: usize,
761 /// Expected length (number of owned CpGs for this haplotype).
762 expected: usize,
763 },
764 /// A record's `MT`/`MB` contains a non-`0`/`1`/`.` character.
765 #[error("variant at pos {pos} hap {hap}: invalid MT/MB character {ch:?}")]
766 InvalidMtMbChar {
767 /// 1-based VCF POS of the offending variant.
768 pos: u32,
769 /// Zero-based haplotype index.
770 hap: usize,
771 /// The offending character.
772 ch: char,
773 },
774 /// A record's `MT` or `MB` field had a `|`-separated entry count that
775 /// did not match the per-sample ploidy. Silent truncation or padding
776 /// could corrupt per-haplotype methylation, so the reader rejects the
777 /// record up front.
778 #[error("variant at pos {pos}: {field} entry count {actual} != expected ploidy {expected}")]
779 PloidyEntryCountMismatch {
780 /// 1-based VCF POS of the offending record.
781 pos: u32,
782 /// Which FORMAT field carried the bad count (`"MT"` or `"MB"`).
783 field: &'static str,
784 /// Number of `|`-separated entries actually present.
785 actual: usize,
786 /// Expected number of entries (per-sample ploidy).
787 expected: usize,
788 },
789 /// Underlying I/O or parse error.
790 #[error("malformed VCF record at line {line}: {message}")]
791 MalformedRecord {
792 /// 1-based line number in the byte slice.
793 line: usize,
794 /// Human-readable description of what was malformed.
795 message: String,
796 },
797}
798
799/// Apply a single `'0'`/`'1'`/`'.'`-encoded bit from a standalone MT/MB
800/// record to the appropriate table position.
801///
802/// `bit_str` is the per-haplotype value from the `|`-separated field (`"0"`,
803/// `"1"`, or `"."`). `hap_top_c_pos` is the 0-based **haplotype**-coordinate
804/// position of the top-strand C of the CpG (already translated from the
805/// VCF's reference POS via `Haplotype::hap_position_for`). `bottom_strand`
806/// selects which bitmap and offset to use: `false` → top at `hap_top_c_pos`;
807/// `true` → bottom at `hap_top_c_pos + 1`. Returns an error if `bit_str`
808/// contains any character other than `'0'`, `'1'`, or `'.'`.
809fn apply_standalone_bit(
810 tables: &mut [crate::meth::MethylationTable],
811 hi: usize,
812 hap_top_c_pos: usize,
813 bit_str: &str,
814 bottom_strand: bool,
815 pos_1based: u32,
816) -> Result<(), ReadError> {
817 match bit_str {
818 "1" => {
819 if bottom_strand {
820 tables[hi].set_bottom(hap_top_c_pos + 1, true);
821 } else {
822 tables[hi].set_top(hap_top_c_pos, true);
823 }
824 }
825 "0" | "." => {} // unmethylated or missing — no-op
826 other => {
827 return Err(ReadError::InvalidMtMbChar {
828 pos: pos_1based,
829 hap: hi,
830 ch: other.chars().next().unwrap_or('?'),
831 });
832 }
833 }
834 Ok(())
835}
836
837/// Apply one haplotype's per-variant bitstring to `tables`, validating length
838/// and character validity. `expected_coords` holds the absolute hap-coords of
839/// each owned CpG's top-strand C. `bottom_strand` selects the bitmap: `false`
840/// → top (set at `hap_coord`); `true` → bottom (set at `hap_coord + 1`).
841/// The strand offset is applied internally — callers pass the top-C coordinate
842/// regardless of strand.
843///
844/// Returns `Ok(())` if `bits` is `"."` (skip) or a valid `'0'`/`'1'`
845/// bitstring of the right length. Errors on length mismatch or bad character.
846fn apply_variant_bits(
847 tables: &mut [crate::meth::MethylationTable],
848 hi: usize,
849 bits: &str,
850 expected_coords: &[u32],
851 bottom_strand: bool,
852 pos_1based: u32,
853) -> Result<(), ReadError> {
854 if bits == "." {
855 // `.` is only valid when this haplotype owns zero CpGs at this
856 // variant (REF allele, missing allele, or an ALT whose owned-CpG
857 // list is empty). When the variants slice says the haplotype does
858 // own CpGs, treat `.` as a length-mismatch parse error instead of
859 // silently dropping the methylation truth.
860 return if expected_coords.is_empty() {
861 Ok(())
862 } else {
863 Err(ReadError::MtMbLengthMismatch {
864 pos: pos_1based,
865 hap: hi,
866 actual: 0,
867 expected: expected_coords.len(),
868 })
869 };
870 }
871 // Validate characters.
872 for ch in bits.chars() {
873 if ch != '0' && ch != '1' {
874 return Err(ReadError::InvalidMtMbChar { pos: pos_1based, hap: hi, ch });
875 }
876 }
877 // Validate length.
878 if bits.len() != expected_coords.len() {
879 return Err(ReadError::MtMbLengthMismatch {
880 pos: pos_1based,
881 hap: hi,
882 actual: bits.len(),
883 expected: expected_coords.len(),
884 });
885 }
886 // Set bits.
887 for (bit_idx, ch) in bits.chars().enumerate() {
888 if ch == '1' {
889 let base_coord = expected_coords[bit_idx] as usize;
890 if bottom_strand {
891 tables[hi].set_bottom(base_coord + 1, true);
892 } else {
893 tables[hi].set_top(base_coord, true);
894 }
895 }
896 }
897 Ok(())
898}
899
900/// Parse MT/MB records from a per-contig VCF byte slice back into a
901/// [`crate::meth::ContigMethylation`].
902///
903/// The byte slice should contain only data lines for `chrom` (no header
904/// lines), one record per line, formatted by [`write_contig`]. Blank lines
905/// are skipped.
906///
907/// Supports both record kinds produced by the writer:
908///
909/// - **Standalone** (`ALT='.'`, `FORMAT=MT:MB`): a single reference-coordinate
910/// CpG row. Each per-haplotype field in `MT`/`MB` is a single `0` or `1`.
911/// - **Variant** (`FORMAT=GT:MT:MB`): a variant row. Per-haplotype entries
912/// in `MT`/`MB` are either `"."` (haplotype carries REF or has no owned
913/// CpGs) or a multi-character bitstring of `'0'`/`'1'` chars.
914///
915/// `reference` is the contig's full reference sequence (uppercase).
916/// `variants` is the sorted variant list for the contig (used to reconstruct
917/// per-haplotype CpG layouts).
918///
919/// Ploidy is derived from `variants` (the max across all records) to match
920/// what [`write_contig`] uses. For a variant-free contig the function falls
921/// back to ploidy `2`, again matching the writer's `unwrap_or(2)`. Keeping
922/// the derivation internal removes a parameter that callers were always
923/// computing the same way and that — when supplied — was silently overridden
924/// for variant-bearing contigs anyway.
925///
926/// # Errors
927///
928/// Returns [`ReadError::MtMbLengthMismatch`] if a variant record's bitstring
929/// length disagrees with the expected number of owned CpGs. Returns
930/// [`ReadError::InvalidMtMbChar`] if a bitstring contains a character other
931/// than `'0'` or `'1'`. Returns [`ReadError::PloidyEntryCountMismatch`] if a
932/// record's `MT` or `MB` field does not contain exactly one `|`-separated
933/// entry per haplotype. Returns [`ReadError::MalformedRecord`] if a line
934/// cannot be parsed at all.
935pub fn read_contig_methylation(
936 vcf_bytes: &[u8],
937 chrom: &str,
938 reference: &[u8],
939 variants: &[VariantRecord],
940 sample_ploidy: usize,
941) -> Result<crate::meth::ContigMethylation, ReadError> {
942 use crate::meth::{ContigMethylation, MethylationTable};
943
944 // One canonical haplotype build per contig (same `(seed=0)` contract
945 // `write_contig` uses), shared with the offset table below.
946 // `sample_ploidy` is the caller-supplied whole-VCF ploidy; on
947 // variant-free contigs it sizes the haplotype slice so we don't fall
948 // back to `2` and silently get the wrong MT/MB shape for haploid /
949 // triploid samples.
950 let haplotypes = build_methylation_haplotypes(variants, sample_ploidy);
951
952 // Build empty per-haplotype tables sized to each haplotype's materialized
953 // length. When there are no variants, every haplotype is the reference.
954 let mut tables: Vec<MethylationTable> = if haplotypes.is_empty() {
955 (0..sample_ploidy).map(|_| MethylationTable::with_len(reference.len())).collect()
956 } else {
957 #[expect(clippy::cast_possible_truncation, reason = "reference length fits in u32")]
958 let ref_len_u32 = reference.len() as u32;
959 haplotypes
960 .iter()
961 .map(|hap| MethylationTable::with_len(hap.hap_position_for(ref_len_u32) as usize))
962 .collect()
963 };
964
965 // Pre-compute per-variant, per-haplotype absolute CpG hap-coords using
966 // the haplotype slice we just built — same `(seed=0)` contract.
967 let var_hap_coords =
968 per_variant_per_hap_cpg_offsets_with_haplotypes(reference, variants, &haplotypes);
969
970 // Pre-build a composite `(POS, REF, ALT, GT)` → variant index map for
971 // O(1) lookups in `apply_variant_record`. Keying by POS alone collapses
972 // multi-allelic / decomposed sites that share a position (e.g. one row
973 // per ALT on different haplotypes), routing every record at that POS
974 // through the wrong `VariantRecord` and corrupting per-haplotype state.
975 // The composite key matches the exact `(REF, ALT, GT)` strings emitted by
976 // [`write_contig`], so writer / reader round-trips disambiguate even when
977 // multiple records share POS.
978 let pos_to_vi: std::collections::HashMap<VariantKey, usize> =
979 variants.iter().enumerate().map(|(i, v)| (variant_key_from_record(v), i)).collect();
980
981 // Fail fast on invalid UTF-8. Silently coercing to an empty body would
982 // return an all-zeros methylation table, indistinguishable from a
983 // legitimately unmethylated contig — exactly the kind of "silent truth
984 // erasure" we work hard to surface everywhere else.
985 let text = std::str::from_utf8(vcf_bytes).map_err(|e| ReadError::MalformedRecord {
986 line: 0,
987 message: format!("VCF body is not valid UTF-8: {e}"),
988 })?;
989 for (line_idx, line) in text.lines().enumerate() {
990 let line_no = line_idx + 1;
991 if line.is_empty() || line.starts_with('#') {
992 continue;
993 }
994 // Minimal tab-split: CHROM POS ID REF ALT QUAL FILTER INFO FORMAT SAMPLE
995 let cols: Vec<&str> = line.splitn(10, '\t').collect();
996 if cols.len() < 10 {
997 return Err(ReadError::MalformedRecord {
998 line: line_no,
999 message: format!("expected 10 tab-separated columns, found {}", cols.len()),
1000 });
1001 }
1002 if cols[0] != chrom {
1003 continue; // defensive: skip records for other contigs
1004 }
1005 let pos_1based: u32 = cols[1].parse().map_err(|_| ReadError::MalformedRecord {
1006 line: line_no,
1007 message: format!("invalid POS field {:?}", cols[1]),
1008 })?;
1009 // VCF POS is 1-based; standalone-record decoding does `pos_1based - 1`
1010 // and the per-haplotype lookup expects a non-negative reference offset.
1011 // Reject `0` here so a malformed input becomes a clean parse error
1012 // rather than a `u32` wraparound and a wildly out-of-range hap-coord.
1013 if pos_1based == 0 {
1014 return Err(ReadError::MalformedRecord {
1015 line: line_no,
1016 message: "POS must be 1-based; got 0".to_string(),
1017 });
1018 }
1019
1020 let ref_str = cols[3];
1021 let alt = cols[4];
1022 let format_col = cols[8];
1023 let sample = cols[9];
1024
1025 if alt == "." {
1026 apply_standalone_record(
1027 &mut tables,
1028 &haplotypes,
1029 pos_1based,
1030 format_col,
1031 sample,
1032 line_no,
1033 )?;
1034 } else {
1035 apply_variant_record(
1036 &mut tables,
1037 pos_1based,
1038 ref_str,
1039 alt,
1040 format_col,
1041 sample,
1042 &pos_to_vi,
1043 &var_hap_coords,
1044 line_no,
1045 )?;
1046 }
1047 }
1048
1049 Ok(ContigMethylation::from_tables(tables))
1050}
1051
1052/// Process one standalone (`ALT='.'`) methylation record, updating `tables`
1053/// in place. `pos_1based` is the 1-based VCF POS. `haplotypes` carries the
1054/// per-haplotype variant layout so the reference-coordinate POS can be
1055/// translated to each haplotype's local coordinate before the bit is set —
1056/// otherwise upstream indels would shift every downstream standalone CpG and
1057/// the bits would land on the wrong positions.
1058///
1059/// Returns a [`ReadError`] on malformed input.
1060fn apply_standalone_record(
1061 tables: &mut [crate::meth::MethylationTable],
1062 haplotypes: &[crate::haplotype::Haplotype],
1063 pos_1based: u32,
1064 format_col: &str,
1065 sample: &str,
1066 line_no: usize,
1067) -> Result<(), ReadError> {
1068 if format_col != "MT:MB" {
1069 return Err(ReadError::MalformedRecord {
1070 line: line_no,
1071 message: format!("standalone record expected FORMAT=MT:MB, got {format_col:?}"),
1072 });
1073 }
1074 let mut parts = sample.splitn(2, ':');
1075 let top_field = parts.next().unwrap_or("");
1076 let bot_field = parts.next().unwrap_or("");
1077
1078 let top_parts: Vec<&str> = top_field.split('|').collect();
1079 let bot_parts: Vec<&str> = bot_field.split('|').collect();
1080 if top_parts.len() != tables.len() {
1081 return Err(ReadError::PloidyEntryCountMismatch {
1082 pos: pos_1based,
1083 field: "MT",
1084 actual: top_parts.len(),
1085 expected: tables.len(),
1086 });
1087 }
1088 if bot_parts.len() != tables.len() {
1089 return Err(ReadError::PloidyEntryCountMismatch {
1090 pos: pos_1based,
1091 field: "MB",
1092 actual: bot_parts.len(),
1093 expected: tables.len(),
1094 });
1095 }
1096
1097 // ref_pos is 0-based; hap_top_c_pos translates it through each haplotype.
1098 let ref_pos = pos_1based - 1;
1099
1100 for (hi, &bit_str) in top_parts.iter().enumerate() {
1101 let hap_top_c_pos =
1102 haplotypes.get(hi).map_or(ref_pos, |h| h.hap_position_for(ref_pos)) as usize;
1103 apply_standalone_bit(tables, hi, hap_top_c_pos, bit_str, false, pos_1based)?;
1104 }
1105 for (hi, &bit_str) in bot_parts.iter().enumerate() {
1106 let hap_top_c_pos =
1107 haplotypes.get(hi).map_or(ref_pos, |h| h.hap_position_for(ref_pos)) as usize;
1108 apply_standalone_bit(tables, hi, hap_top_c_pos, bit_str, true, pos_1based)?;
1109 }
1110 Ok(())
1111}
1112
1113/// Process one variant (`ALT != '.'`) methylation record, updating `tables`
1114/// in place. Looks the record up via `pos_to_vi` (a pre-built
1115/// `(POS, REF, ALT, GT)` → variant index map; see [`VariantKey`]) and
1116/// applies the per-haplotype MT/MB bitstrings using `var_hap_coords`. Keying
1117/// on the full record identity rather than POS alone is what lets multiple
1118/// VCF rows at the same position resolve to their correct `VariantRecord`.
1119#[allow(clippy::too_many_arguments)] // tight internal helper; one call site
1120fn apply_variant_record(
1121 tables: &mut [crate::meth::MethylationTable],
1122 pos_1based: u32,
1123 ref_str: &str,
1124 alt_str: &str,
1125 format_col: &str,
1126 sample: &str,
1127 pos_to_vi: &std::collections::HashMap<VariantKey, usize>,
1128 var_hap_coords: &[Vec<Vec<u32>>],
1129 line_no: usize,
1130) -> Result<(), ReadError> {
1131 if format_col != "GT:MT:MB" {
1132 return Err(ReadError::MalformedRecord {
1133 line: line_no,
1134 message: format!("variant record expected FORMAT=GT:MT:MB, got {format_col:?}"),
1135 });
1136 }
1137
1138 // Split GT:MT:MB; capture GT so we can build the composite lookup key.
1139 let mut parts = sample.splitn(3, ':');
1140 let gt_str = parts.next().unwrap_or("");
1141 let top_field = parts.next().unwrap_or("");
1142 let bot_field = parts.next().unwrap_or("");
1143
1144 let key = VariantKey {
1145 pos_1based,
1146 ref_allele: ref_str.to_string(),
1147 alt_alleles: alt_str.to_string(),
1148 gt: gt_str.to_string(),
1149 };
1150 let vi = pos_to_vi.get(&key).copied().ok_or_else(|| ReadError::MalformedRecord {
1151 line: line_no,
1152 message: format!(
1153 "variant at POS {pos_1based} (REF={ref_str}, ALT={alt_str}, GT={gt_str}) \
1154 not found in provided variants slice"
1155 ),
1156 })?;
1157
1158 let top_parts: Vec<&str> = top_field.split('|').collect();
1159 let bot_parts: Vec<&str> = bot_field.split('|').collect();
1160 if top_parts.len() != tables.len() {
1161 return Err(ReadError::PloidyEntryCountMismatch {
1162 pos: pos_1based,
1163 field: "MT",
1164 actual: top_parts.len(),
1165 expected: tables.len(),
1166 });
1167 }
1168 if bot_parts.len() != tables.len() {
1169 return Err(ReadError::PloidyEntryCountMismatch {
1170 pos: pos_1based,
1171 field: "MB",
1172 actual: bot_parts.len(),
1173 expected: tables.len(),
1174 });
1175 }
1176
1177 let hap_coords_for_var =
1178 if vi < var_hap_coords.len() { var_hap_coords[vi].as_slice() } else { &[] };
1179
1180 for (hi, &bits) in top_parts.iter().enumerate() {
1181 let expected: &[u32] =
1182 if hi < hap_coords_for_var.len() { &hap_coords_for_var[hi] } else { &[] };
1183 apply_variant_bits(tables, hi, bits, expected, false, pos_1based)?;
1184 }
1185 for (hi, &bits) in bot_parts.iter().enumerate() {
1186 let expected: &[u32] =
1187 if hi < hap_coords_for_var.len() { &hap_coords_for_var[hi] } else { &[] };
1188 apply_variant_bits(tables, hi, bits, expected, true, pos_1based)?;
1189 }
1190 Ok(())
1191}
1192
1193/// Build the `|`-separated per-haplotype top-strand bit string for a
1194/// standalone CpG whose top-C lives at reference position `ref_pos`.
1195/// `"1"` for methylated, `"0"` for not.
1196///
1197/// Per-haplotype methylation tables are indexed in *haplotype* coordinates
1198/// (built from the materialized haplotype sequence in
1199/// [`crate::meth::MethylationTable::from_haplotype`]), so this maps
1200/// `ref_pos` through each haplotype's `hap_position_for` before querying.
1201/// Without that mapping, every standalone CpG downstream of an indel would
1202/// read the wrong bit. When `haplotypes` is empty (no variants → no
1203/// indels), the function falls back to `ref_pos` directly since
1204/// `hap_position_for(r) == r` in that case.
1205fn per_hap_top_bit_string(
1206 cm: &crate::meth::ContigMethylation,
1207 haplotypes: &[crate::haplotype::Haplotype],
1208 ref_pos: u32,
1209) -> String {
1210 (0..cm.len())
1211 .map(|i| {
1212 let hap_pos = haplotypes.get(i).map_or(ref_pos, |h| h.hap_position_for(ref_pos));
1213 if cm.table_for(i).is_methylated(hap_pos, false) { "1" } else { "0" }
1214 })
1215 .collect::<Vec<_>>()
1216 .join("|")
1217}
1218
1219/// Build the `|`-separated per-haplotype bottom-strand bit string for the
1220/// CpG whose top-C lives at reference position `ref_pos`. The
1221/// bottom-strand C lives at the G's position — `ref_pos + 1` in reference
1222/// coordinates, `hap_pos + 1` in each haplotype's coordinates (a standalone
1223/// CpG is undisturbed by variants, so the G is adjacent to the C on every
1224/// haplotype). See [`per_hap_top_bit_string`] for the rationale behind the
1225/// `hap_position_for` mapping.
1226fn per_hap_bot_bit_string(
1227 cm: &crate::meth::ContigMethylation,
1228 haplotypes: &[crate::haplotype::Haplotype],
1229 ref_pos: u32,
1230) -> String {
1231 (0..cm.len())
1232 .map(|i| {
1233 let hap_pos = haplotypes.get(i).map_or(ref_pos, |h| h.hap_position_for(ref_pos));
1234 if cm.table_for(i).is_methylated(hap_pos + 1, true) { "1" } else { "0" }
1235 })
1236 .collect::<Vec<_>>()
1237 .join("|")
1238}
1239
1240/// Open a VCF file as a buffered line reader, transparently decompressing
1241/// BGZF-/gzip-compressed inputs detected by the gzip magic bytes
1242/// `0x1f 0x8b` at the start of the file. Returns a boxed `BufRead` so
1243/// header-only probes and full-body readers can share the open/peek logic.
1244fn open_vcf_buf_reader(path: &std::path::Path) -> std::io::Result<Box<dyn std::io::BufRead>> {
1245 use std::io::{BufReader, Read as _};
1246 let mut peek_buf = [0u8; 2];
1247 {
1248 let mut f = std::fs::File::open(path)?;
1249 f.read_exact(&mut peek_buf)?;
1250 }
1251 let file = std::fs::File::open(path)?;
1252 if peek_buf == [0x1f, 0x8b] {
1253 Ok(Box::new(BufReader::new(flate2::read::MultiGzDecoder::new(file))))
1254 } else {
1255 Ok(Box::new(BufReader::new(file)))
1256 }
1257}
1258
1259/// Check whether a VCF actually carries methylation truth: the header must
1260/// declare both `MT` and `MB` FORMAT fields **and** at least one data record
1261/// must list `MT` (or `MB`) in its FORMAT column.
1262///
1263/// Header declarations alone are not enough — a VCF can declare `MT`/`MB`
1264/// in the header yet contain zero annotated records (e.g. an aborted
1265/// `holodeck methylate` run, or a hand-edited header). Such a file has no
1266/// methylation truth to apply; treating it as methylated lets `simulate`
1267/// past `validate()` only to hit an internal-invariant failure deep in the
1268/// per-contig loop. Requiring a real record turns that into a clean,
1269/// up-front user-facing error instead.
1270///
1271/// Streams line-by-line (no full-body buffering) and early-exits: it returns
1272/// `false` immediately at the column-header line if the header lacked
1273/// `MT`/`MB`, and returns `true` on the first record whose FORMAT names
1274/// `MT`/`MB`. Worst case (header declares the fields but no record uses them)
1275/// scans the whole body, which is unavoidable to prove a negative. Supports
1276/// plain-text and BGZF-compressed inputs.
1277///
1278/// Used by `Simulate::validate` to decide upfront whether methylation
1279/// chemistry can be applied.
1280///
1281/// # Errors
1282///
1283/// Returns an I/O error if the file cannot be opened or read.
1284pub fn vcf_has_mt_mb_records(path: &std::path::Path) -> std::io::Result<bool> {
1285 use std::io::BufRead as _;
1286 let mut reader = open_vcf_buf_reader(path)?;
1287 let mut saw_top_strand_field = false;
1288 let mut saw_bot_strand_field = false;
1289 let mut line = String::new();
1290 loop {
1291 line.clear();
1292 let n = reader.read_line(&mut line)?;
1293 if n == 0 {
1294 break; // EOF — header declared MT/MB but no record used it.
1295 }
1296 let trimmed = line.trim_end_matches(['\n', '\r']);
1297 if trimmed.starts_with("##") {
1298 if trimmed.contains("ID=MT,") {
1299 saw_top_strand_field = true;
1300 }
1301 if trimmed.contains("ID=MB,") {
1302 saw_bot_strand_field = true;
1303 }
1304 continue;
1305 }
1306 // Column-header (`#CHROM…`) or a data line: if the header never
1307 // declared both fields, no record can carry methylation truth.
1308 if !(saw_top_strand_field && saw_bot_strand_field) {
1309 return Ok(false);
1310 }
1311 if trimmed.starts_with('#') {
1312 continue; // the `#CHROM` line itself carries no FORMAT
1313 }
1314 // Data line: FORMAT is the 9th tab-separated column (index 8), a
1315 // colon-separated list of keys. A real methylation record names
1316 // `MT`/`MB` there.
1317 if let Some(format_col) = trimmed.split('\t').nth(8)
1318 && format_col.split(':').any(|k| k == "MT" || k == "MB")
1319 {
1320 return Ok(true);
1321 }
1322 }
1323 Ok(false)
1324}
1325
1326/// Pre-parsed methylation VCF body: header MT/MB detection plus per-contig
1327/// record bytes ready for [`read_contig_methylation`].
1328///
1329/// Built once per VCF by [`parse_methylation_vcf`]. Reusing it across the
1330/// per-contig loop avoids re-decompressing and re-scanning the full VCF on
1331/// every contig — the cost previously paid by the now-removed
1332/// `load_contig_methylation_if_present`.
1333#[derive(Debug, Default)]
1334pub struct MethylationVcfRecords {
1335 /// True iff the VCF header declared both `MT` and `MB` FORMAT fields.
1336 pub has_mt_mb: bool,
1337 /// Per-contig record lines (header excluded), each `\n`-terminated and
1338 /// concatenated, ready to pass as the byte slice to
1339 /// [`read_contig_methylation`]. Contigs absent from the file map to an
1340 /// empty `Vec`.
1341 pub per_contig: std::collections::HashMap<String, Vec<u8>>,
1342}
1343
1344/// Read and split a methylation-annotated VCF into [`MethylationVcfRecords`]
1345/// in a single pass: decompresses (BGZF or plain) once, detects whether the
1346/// header declares MT/MB, and partitions data lines by contig.
1347///
1348/// Callers that need per-contig methylation truth (e.g. `simulate`) should
1349/// call this once before iterating contigs and then call
1350/// [`load_contig_methylation_from_records`] inside the loop, rather than
1351/// re-reading the VCF per contig.
1352///
1353/// # Errors
1354///
1355/// Returns an I/O error if the file cannot be opened or read.
1356pub fn parse_methylation_vcf(path: &std::path::Path) -> std::io::Result<MethylationVcfRecords> {
1357 use std::io::BufRead as _;
1358
1359 // Stream the VCF line-by-line into the per-contig record map. The
1360 // previous implementation called `read_to_string` first, doubling peak
1361 // memory: the whole-file `String` and the per-contig `Vec<u8>`s held
1362 // the body bytes twice. Streaming halves that to roughly one body
1363 // worth (the `HashMap` values) plus a single `read_line` buffer.
1364 let mut reader = open_vcf_buf_reader(path)?;
1365
1366 let mut saw_top_strand_field = false;
1367 let mut saw_bot_strand_field = false;
1368 let mut per_contig: std::collections::HashMap<String, Vec<u8>> =
1369 std::collections::HashMap::new();
1370 let mut past_header = false;
1371 let mut line = String::new();
1372 loop {
1373 line.clear();
1374 let n = reader.read_line(&mut line)?;
1375 if n == 0 {
1376 break; // EOF
1377 }
1378 let trimmed = line.trim_end_matches(['\n', '\r']);
1379 if trimmed.starts_with("##") {
1380 // Two independent checks (not `else if`) so a hand-edited header
1381 // that crams both fields onto one `##FORMAT` line is still
1382 // recognized. The writer emits them on separate lines, but
1383 // external tools and manual edits don't always.
1384 if trimmed.contains("ID=MT,") {
1385 saw_top_strand_field = true;
1386 }
1387 if trimmed.contains("ID=MB,") {
1388 saw_bot_strand_field = true;
1389 }
1390 } else if trimmed.starts_with('#') {
1391 past_header = true;
1392 if !(saw_top_strand_field && saw_bot_strand_field) {
1393 // No MT/MB → no records to collect; return early with the flag.
1394 return Ok(MethylationVcfRecords::default());
1395 }
1396 } else if past_header {
1397 let Some(chrom) = trimmed.split('\t').next() else {
1398 log::debug!("skipping malformed VCF line: {trimmed}");
1399 continue;
1400 };
1401 let entry = per_contig.entry(chrom.to_string()).or_default();
1402 entry.extend_from_slice(trimmed.as_bytes());
1403 entry.push(b'\n');
1404 }
1405 }
1406
1407 let has_mt_mb = saw_top_strand_field && saw_bot_strand_field;
1408 Ok(MethylationVcfRecords {
1409 has_mt_mb,
1410 per_contig: if has_mt_mb { per_contig } else { std::collections::HashMap::new() },
1411 })
1412}
1413
1414/// Look up the parsed methylation truth for a single contig from the
1415/// pre-parsed [`MethylationVcfRecords`].
1416///
1417/// Returns `Ok(None)` if the source VCF lacked MT/MB FORMAT (signalling that
1418/// the file carries no methylation truth). Contigs absent from the file
1419/// produce an empty-but-valid [`crate::meth::ContigMethylation`].
1420///
1421/// The MT/MB encoding is the round-trip pair to [`write_contig`]; the
1422/// reader expects the writer's format exactly.
1423///
1424/// `reference` is the contig's full reference sequence (uppercase).
1425/// `variants` is the sorted variant list for the contig (same contract as
1426/// [`read_contig_methylation`]). `sample_ploidy` is the whole-VCF ploidy
1427/// resolved via [`resolve_sample_ploidy`]; threading it down ensures
1428/// variant-free contigs are sized the same as variant-bearing ones on
1429/// haploid / triploid / etc. samples.
1430///
1431/// # Errors
1432///
1433/// Returns an error if [`read_contig_methylation`] rejects a record.
1434pub fn load_contig_methylation_from_records(
1435 records: &MethylationVcfRecords,
1436 contig_name: &str,
1437 reference: &[u8],
1438 variants: &[crate::vcf::genotype::VariantRecord],
1439 sample_ploidy: usize,
1440) -> anyhow::Result<Option<crate::meth::ContigMethylation>> {
1441 if !records.has_mt_mb {
1442 return Ok(None);
1443 }
1444 let empty: Vec<u8> = Vec::new();
1445 let bytes = records.per_contig.get(contig_name).unwrap_or(&empty);
1446 let cm = read_contig_methylation(bytes, contig_name, reference, variants, sample_ploidy)
1447 .map_err(|e| anyhow::anyhow!("failed to parse MT/MB for {contig_name}: {e}"))?;
1448 log::debug!("Loaded methylation truth for {contig_name} from VCF MT/MB");
1449 Ok(Some(cm))
1450}
1451
1452#[cfg(test)]
1453mod roundtrip_tests {
1454 use super::*;
1455 use crate::meth::{ContigMethylation, MethylationTable};
1456
1457 #[test]
1458 fn standalone_record_round_trip() {
1459 // Reference with one CpG at position 1 (0-based). No variants.
1460 // Hap 0 has the top-strand C at ref pos 1 methylated; hap 1 is
1461 // fully unmethylated. Neither haplotype has any bottom-strand
1462 // methylation.
1463 let reference = b"ACGT";
1464 let mut h0 = MethylationTable::empty(4);
1465 h0.set_top(1, true);
1466 let h1 = MethylationTable::empty(4);
1467 let cm_in = ContigMethylation::from_tables(vec![h0, h1]);
1468
1469 let mut buf = Vec::new();
1470 write_contig(&mut std::io::Cursor::new(&mut buf), "chr1", reference, &[], &cm_in, 2)
1471 .unwrap();
1472
1473 let cm_out = read_contig_methylation(&buf, "chr1", reference, &[], 2).unwrap();
1474 assert_eq!(cm_out.len(), 2);
1475 // Hap 0: top-strand bit at ref pos 1 must survive the round trip.
1476 assert!(cm_out.table_for(0).is_methylated(1, false), "hap0 top should be methylated");
1477 // Hap 1: all bits remain false.
1478 assert!(!cm_out.table_for(1).is_methylated(1, false), "hap1 top should not be methylated");
1479 // Bottom-strand bits were not set by the writer, so both should be false.
1480 assert!(
1481 !cm_out.table_for(0).is_methylated(2, true),
1482 "hap0 bottom should not be methylated"
1483 );
1484 assert!(
1485 !cm_out.table_for(1).is_methylated(2, true),
1486 "hap1 bottom should not be methylated"
1487 );
1488 }
1489}
1490
1491#[cfg(test)]
1492mod fuzz_tests {
1493 //! Closed-loop fuzz round-trip tests for the methylation VCF format.
1494 //!
1495 //! Confirms that [`write_contig`] + [`read_contig_methylation`] form a
1496 //! faithful round-trip pair across random inputs, covering both the
1497 //! no-variant (standalone-record-only) path and the variant-bearing path.
1498
1499 use rand::Rng as _;
1500 use rand::SeedableRng;
1501 use rand::rngs::SmallRng;
1502
1503 use super::{read_contig_methylation, write_contig};
1504 use crate::haplotype::build_haplotypes;
1505 use crate::meth::{ContigMethylation, MethylationTable};
1506 use crate::vcf::genotype::{Genotype, VariantRecord};
1507
1508 /// Assert per-position, per-strand, per-haplotype equality between two
1509 /// [`ContigMethylation`] values of equal ploidy.
1510 ///
1511 /// `hap_lengths[hi]` is the number of positions in haplotype `hi`'s bitmap.
1512 fn assert_methylation_eq(
1513 cm_in: &ContigMethylation,
1514 cm_out: &ContigMethylation,
1515 hap_lengths: &[usize],
1516 ) {
1517 assert_eq!(cm_in.len(), cm_out.len(), "haplotype count mismatch");
1518 for (hap, &len) in hap_lengths.iter().enumerate().take(cm_in.len()) {
1519 #[expect(clippy::cast_possible_truncation, reason = "hap length fits u32")]
1520 for pos in 0..len as u32 {
1521 assert_eq!(
1522 cm_in.table_for(hap).is_methylated(pos, false),
1523 cm_out.table_for(hap).is_methylated(pos, false),
1524 "top mismatch at hap {hap} pos {pos}",
1525 );
1526 assert_eq!(
1527 cm_in.table_for(hap).is_methylated(pos, true),
1528 cm_out.table_for(hap).is_methylated(pos, true),
1529 "bottom mismatch at hap {hap} pos {pos}",
1530 );
1531 }
1532 }
1533 }
1534
1535 #[test]
1536 fn roundtrip_random_bitmap_no_variants() {
1537 let mut rng = SmallRng::seed_from_u64(42);
1538 // 10 kb of random ACGT, no variants → every haplotype is the reference.
1539 let reference: Vec<u8> =
1540 (0..10_000).map(|_| b"ACGT"[rng.random_range(0..4usize)]).collect();
1541
1542 // Set random methylation on two diploid haplotypes at every CpG.
1543 let mut h0 = MethylationTable::with_len(reference.len());
1544 let mut h1 = MethylationTable::with_len(reference.len());
1545 for i in 0..reference.len() - 1 {
1546 if reference[i] == b'C' && reference[i + 1] == b'G' {
1547 h0.set_top(i, rng.random_bool(0.7));
1548 h0.set_bottom(i + 1, rng.random_bool(0.7));
1549 h1.set_top(i, rng.random_bool(0.7));
1550 h1.set_bottom(i + 1, rng.random_bool(0.7));
1551 }
1552 }
1553 let cm_in = ContigMethylation::from_tables(vec![h0, h1]);
1554
1555 let mut buf = Vec::new();
1556 write_contig(&mut std::io::Cursor::new(&mut buf), "chr1", &reference, &[], &cm_in, 2)
1557 .unwrap();
1558 let cm_out = read_contig_methylation(&buf, "chr1", &reference, &[], 2).unwrap();
1559
1560 assert_methylation_eq(&cm_in, &cm_out, &[reference.len(), reference.len()]);
1561 }
1562
1563 #[test]
1564 fn roundtrip_random_bitmap_with_phased_variants() {
1565 // 500 bp reference with several non-overlapping phased SNPs. For each
1566 // haplotype, materialise the full sequence, scan for CpGs, and set
1567 // random top/bottom methylation bits. Write the result to a VCF byte
1568 // buffer via write_contig, read it back via read_contig_methylation,
1569 // and assert exact bit-for-bit equality at every haplotype position.
1570 //
1571 // The writer and reader both call build_haplotypes(variants, 2,
1572 // seed_from_u64(0)) internally, so the haplotype layout is identical
1573 // on both sides.
1574
1575 // --- Build reference ---
1576 let reference: Vec<u8> = b"ACGTCGATCGATCGCGATCGACGT\
1577 ACGTCGATCGATCGCGATCGACGT\
1578 ACGTCGATCGATCGCGATCGACGT\
1579 ACGTCGATCGATCGCGATCGACGT\
1580 ACGTCGATCGATCGCGATCGACGT\
1581 ACGTCGATCGATCGCGATCGACGT\
1582 ACGTCGATCGATCGCGATCGACGT\
1583 ACGTCGATCGATCGCGATCGACGT\
1584 ACGTCGATCGATCGCGATCGACGT\
1585 ACGTCGATCGATCGCGATCGACGT\
1586 ACGTCGATCGATCGCGATCGACGT\
1587 ACGTCGATCGATCGCGATCGACGT\
1588 ACGTCGATCGATCGCGATCGACGT\
1589 ACGTCGATCGATCGCGATCGACGT\
1590 ACGTCGATCGATCGCGATCGACGT\
1591 ACGTCGATCGATCGCGATCGACGT\
1592 ACGTCGATCGATCGCGATCGACGT\
1593 ACGTCGATCGATCGCGATCGACGT\
1594 ACGTCGATCGATCGCGATCGACGT\
1595 ACGTCGATCGATCGCGATCGACGT"
1596 .to_vec();
1597
1598 // Hand-crafted non-overlapping phased SNPs, all well away from each
1599 // other and away from the reference ends.
1600 // GT "1|0" → hap 0 carries ALT, hap 1 carries REF.
1601 // GT "0|1" → hap 0 carries REF, hap 1 carries ALT.
1602 // None introduce CpGs that straddle variant boundaries in a way that
1603 // changes haplotype length (these are all single-base SNPs).
1604 let variants: Vec<VariantRecord> = vec![
1605 VariantRecord {
1606 position: 10,
1607 ref_allele: b"A".to_vec(),
1608 alt_alleles: vec![b"T".to_vec()],
1609 genotype: Genotype::parse("1|0").unwrap(),
1610 },
1611 VariantRecord {
1612 position: 50,
1613 ref_allele: b"T".to_vec(),
1614 alt_alleles: vec![b"A".to_vec()],
1615 genotype: Genotype::parse("0|1").unwrap(),
1616 },
1617 VariantRecord {
1618 position: 100,
1619 ref_allele: b"G".to_vec(),
1620 alt_alleles: vec![b"C".to_vec()],
1621 genotype: Genotype::parse("1|0").unwrap(),
1622 },
1623 VariantRecord {
1624 position: 200,
1625 ref_allele: b"C".to_vec(),
1626 alt_alleles: vec![b"A".to_vec()],
1627 genotype: Genotype::parse("0|1").unwrap(),
1628 },
1629 VariantRecord {
1630 position: 350,
1631 ref_allele: b"A".to_vec(),
1632 alt_alleles: vec![b"G".to_vec()],
1633 genotype: Genotype::parse("1|0").unwrap(),
1634 },
1635 ];
1636
1637 // Materialise haplotypes with the same seed the writer/reader use.
1638 let haplotypes = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(0));
1639
1640 // Determine per-haplotype bitmap lengths.
1641 #[expect(clippy::cast_possible_truncation, reason = "reference length fits u32")]
1642 let ref_len_u32 = reference.len() as u32;
1643 let hap_lengths: Vec<usize> =
1644 haplotypes.iter().map(|h| h.hap_position_for(ref_len_u32) as usize).collect();
1645
1646 // Build per-haplotype methylation tables with random CpG bits.
1647 let mut rng = SmallRng::seed_from_u64(7);
1648 let tables: Vec<MethylationTable> = haplotypes
1649 .iter()
1650 .zip(hap_lengths.iter())
1651 .map(|(hap, &len)| {
1652 let cap = len;
1653 let (hap_bases, _ref_positions, _hap_start) =
1654 hap.extract_fragment(&reference, 0, cap);
1655 let mut table = MethylationTable::with_len(len);
1656 for i in 0..hap_bases.len().saturating_sub(1) {
1657 let c0 = hap_bases[i].to_ascii_uppercase();
1658 let c1 = hap_bases[i + 1].to_ascii_uppercase();
1659 if c0 == b'C' && c1 == b'G' {
1660 table.set_top(i, rng.random_bool(0.6));
1661 table.set_bottom(i + 1, rng.random_bool(0.6));
1662 }
1663 }
1664 table
1665 })
1666 .collect();
1667 let cm_in = ContigMethylation::from_tables(tables);
1668
1669 // Round-trip through write_contig → read_contig_methylation.
1670 let mut buf = Vec::new();
1671 write_contig(&mut std::io::Cursor::new(&mut buf), "chr1", &reference, &variants, &cm_in, 2)
1672 .unwrap();
1673 let cm_out = read_contig_methylation(&buf, "chr1", &reference, &variants, 2).unwrap();
1674
1675 assert_methylation_eq(&cm_in, &cm_out, &hap_lengths);
1676 }
1677
1678 #[test]
1679 fn roundtrip_triploid_with_phased_variants() {
1680 // Ploidy 3 exercises the per-haplotype index path beyond the diploid
1681 // cases above: three distinct bitmaps, phased GTs that put ALT on
1682 // different subsets of the three haplotypes, and an insertion that
1683 // shifts downstream coordinates on only the haplotypes carrying it.
1684 let reference: Vec<u8> = b"ACGTCGATCGATCGCGATCGACGT\
1685 ACGTCGATCGATCGCGATCGACGT\
1686 ACGTCGATCGATCGCGATCGACGT\
1687 ACGTCGATCGATCGCGATCGACGT\
1688 ACGTCGATCGATCGCGATCGACGT"
1689 .to_vec();
1690
1691 // "1|0|1" → haps 0 and 2 carry ALT; "0|1|0" → only hap 1; the
1692 // insertion "0|0|1" shifts downstream coordinates on hap 2 alone.
1693 let variants: Vec<VariantRecord> = vec![
1694 VariantRecord {
1695 position: 12,
1696 ref_allele: b"A".to_vec(),
1697 alt_alleles: vec![b"T".to_vec()],
1698 genotype: Genotype::parse("1|0|1").unwrap(),
1699 },
1700 VariantRecord {
1701 position: 40,
1702 ref_allele: b"T".to_vec(),
1703 alt_alleles: vec![b"A".to_vec()],
1704 genotype: Genotype::parse("0|1|0").unwrap(),
1705 },
1706 VariantRecord {
1707 position: 70,
1708 ref_allele: b"A".to_vec(),
1709 alt_alleles: vec![b"AGGG".to_vec()],
1710 genotype: Genotype::parse("0|0|1").unwrap(),
1711 },
1712 ];
1713
1714 let haplotypes = build_haplotypes(&variants, 3, &mut SmallRng::seed_from_u64(0));
1715 #[expect(clippy::cast_possible_truncation, reason = "reference length fits u32")]
1716 let ref_len_u32 = reference.len() as u32;
1717 let hap_lengths: Vec<usize> =
1718 haplotypes.iter().map(|h| h.hap_position_for(ref_len_u32) as usize).collect();
1719
1720 let mut rng = SmallRng::seed_from_u64(11);
1721 let tables: Vec<MethylationTable> = haplotypes
1722 .iter()
1723 .zip(hap_lengths.iter())
1724 .map(|(hap, &len)| {
1725 let (hap_bases, _ref_positions, _hap_start) =
1726 hap.extract_fragment(&reference, 0, len);
1727 let mut table = MethylationTable::with_len(len);
1728 for i in 0..hap_bases.len().saturating_sub(1) {
1729 if hap_bases[i].eq_ignore_ascii_case(&b'C')
1730 && hap_bases[i + 1].eq_ignore_ascii_case(&b'G')
1731 {
1732 table.set_top(i, rng.random_bool(0.6));
1733 table.set_bottom(i + 1, rng.random_bool(0.6));
1734 }
1735 }
1736 table
1737 })
1738 .collect();
1739 let cm_in = ContigMethylation::from_tables(tables);
1740 assert_eq!(cm_in.len(), 3, "fixture must be triploid");
1741
1742 let mut buf = Vec::new();
1743 write_contig(&mut std::io::Cursor::new(&mut buf), "chr1", &reference, &variants, &cm_in, 3)
1744 .unwrap();
1745 let cm_out = read_contig_methylation(&buf, "chr1", &reference, &variants, 3).unwrap();
1746
1747 assert_methylation_eq(&cm_in, &cm_out, &hap_lengths);
1748 }
1749
1750 #[test]
1751 fn standalone_cpg_downstream_of_indel_round_trips_per_haplotype() {
1752 // Regression: an earlier writer/reader treated `ref_pos` as a
1753 // haplotype coordinate when emitting/parsing standalone CpG records.
1754 // On a haplotype with an upstream insertion, every downstream
1755 // standalone CpG's bit was written and read at the wrong bitmap
1756 // index. This test pins the per-haplotype mapping in place.
1757 //
1758 // Reference layout (0-based):
1759 // pos 0 5 10 15 20 25
1760 // ref ATATA T CGATA CGATA CGATA CG (CpGs at 6, 11, 16, 21, 24)
1761 //
1762 // Variant: 3 bp insertion at position 3 on hap 0 only ("0|1" → hap 1
1763 // carries ALT, hap 0 carries REF). On hap 1, every downstream
1764 // standalone CpG's haplotype coordinate is shifted by +3.
1765 let reference: Vec<u8> = b"ATATATCGATACGATACGATACGCG".to_vec();
1766 let variants: Vec<VariantRecord> = vec![VariantRecord {
1767 position: 3,
1768 ref_allele: b"T".to_vec(),
1769 alt_alleles: vec![b"TGGG".to_vec()],
1770 genotype: Genotype::parse("0|1").unwrap(),
1771 }];
1772
1773 // Build haplotypes with the same seed the writer/reader use.
1774 let haplotypes = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(0));
1775 #[expect(clippy::cast_possible_truncation, reason = "reference length fits u32")]
1776 let ref_len_u32 = reference.len() as u32;
1777 let hap_lengths: Vec<usize> =
1778 haplotypes.iter().map(|h| h.hap_position_for(ref_len_u32) as usize).collect();
1779
1780 // Set distinctive per-haplotype methylation: hap 0 has top-strand
1781 // bits set at every CpG-context C in its (insertion-free) sequence;
1782 // hap 1 has bottom-strand bits set at every G of every CpG. Both
1783 // patterns rely on per-haplotype coordinates being correct.
1784 let mut tables: Vec<MethylationTable> = haplotypes
1785 .iter()
1786 .zip(hap_lengths.iter())
1787 .map(|(hap, &len)| {
1788 let (hap_bases, _ref_positions, _hap_start) =
1789 hap.extract_fragment(&reference, 0, len);
1790 let mut table = MethylationTable::with_len(len);
1791 for i in 0..hap_bases.len().saturating_sub(1) {
1792 if hap_bases[i].eq_ignore_ascii_case(&b'C')
1793 && hap_bases[i + 1].eq_ignore_ascii_case(&b'G')
1794 {
1795 table.set_top(i, true);
1796 table.set_bottom(i + 1, true);
1797 }
1798 }
1799 table
1800 })
1801 .collect();
1802 // Pin a hap-specific asymmetry so the round-trip would catch any
1803 // off-by-three between writer and reader: clear hap 1's first CpG's
1804 // top-strand bit.
1805 let hap1_first_cpg_hap_pos = haplotypes[1].hap_position_for(6) as usize;
1806 tables[1].set_top(hap1_first_cpg_hap_pos, false);
1807 let cm_in = ContigMethylation::from_tables(tables);
1808
1809 let mut buf = Vec::new();
1810 write_contig(&mut std::io::Cursor::new(&mut buf), "chr1", &reference, &variants, &cm_in, 2)
1811 .unwrap();
1812 let cm_out = read_contig_methylation(&buf, "chr1", &reference, &variants, 2).unwrap();
1813
1814 assert_methylation_eq(&cm_in, &cm_out, &hap_lengths);
1815
1816 // Independent positive check: the wrong-path code would write hap
1817 // 1's first standalone CpG bit at hap-coord 6 (treating ref_pos as
1818 // hap_pos). Confirm the bit actually landed at the shifted
1819 // hap-coordinate, not at the reference position.
1820 let hap1_table = cm_out.table_for(1);
1821 let cpg_ref_pos: u32 = 11; // second reference CpG
1822 let cpg_hap_pos = haplotypes[1].hap_position_for(cpg_ref_pos);
1823 assert_ne!(cpg_hap_pos, cpg_ref_pos, "test setup assumes indel shifts hap 1");
1824 assert!(
1825 hap1_table.is_methylated(cpg_hap_pos, false),
1826 "hap 1 CpG at hap-coord {cpg_hap_pos} (ref pos {cpg_ref_pos}) should be methylated",
1827 );
1828 assert!(
1829 !hap1_table.is_methylated(cpg_ref_pos, false),
1830 "hap 1 should NOT have a top-strand bit at ref pos {cpg_ref_pos} \
1831 (that would indicate ref_pos was used as a hap coord)",
1832 );
1833 }
1834}
1835
1836#[cfg(test)]
1837mod writer_tests {
1838 use super::*;
1839 use crate::meth::{ContigMethylation, MethylationTable};
1840 use std::io::Cursor;
1841
1842 #[test]
1843 fn writes_methylation_only_record_for_reference_cpg() {
1844 // Reference with one CpG at position 1 (0-based), no variants.
1845 // Hap 0 is fully methylated on the top strand; hap 1 is unmethylated
1846 // everywhere. Both haplotypes are unmethylated on the bottom strand.
1847 let reference = b"ACGT";
1848 let mut h0 = MethylationTable::empty(4);
1849 h0.set_top(1, true);
1850 let h1 = MethylationTable::empty(4);
1851 let cm = ContigMethylation::from_tables(vec![h0, h1]);
1852 let mut buf = Vec::new();
1853 write_contig(&mut Cursor::new(&mut buf), "chr1", reference, &[], &cm, 2).unwrap();
1854 let s = String::from_utf8(buf).unwrap();
1855 // VCF POS is 1-based; CpG top-C is at ref pos 1 (0-based) → POS 2.
1856 assert!(s.contains("chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t1|0:0|0"), "got: {s}");
1857 }
1858
1859 #[test]
1860 fn writes_variant_record_with_mt_mb_for_alt_cpg() {
1861 use crate::vcf::genotype::Genotype;
1862 // ref: AAATTAA; SNPs at 3 (T->C) and 4 (T->G) on hap0 -> CpG in alt on hap0.
1863 let reference = b"AAATTAA";
1864 let variants = vec![
1865 VariantRecord {
1866 position: 3,
1867 ref_allele: b"T".to_vec(),
1868 alt_alleles: vec![b"C".to_vec()],
1869 genotype: Genotype::parse("1|0").unwrap(),
1870 },
1871 VariantRecord {
1872 position: 4,
1873 ref_allele: b"T".to_vec(),
1874 alt_alleles: vec![b"G".to_vec()],
1875 genotype: Genotype::parse("1|0").unwrap(),
1876 },
1877 ];
1878 // Build a per-haplotype methylation table for hap0 with the alt-CpG
1879 // methylated on both strands; hap1 has nothing.
1880 //
1881 // Haplotype 0 (hap_allele_index=0) carries allele Some(1) at both
1882 // variants (GT="1|0", so allele_index 0 maps to allele 1). When
1883 // build_haplotypes runs, hap0 carries T->C at position 3 and T->G at
1884 // position 4, materializing as AAACGAA (CG at hap-coords 3,4).
1885 // So top-strand C is at hap-coord 3, bottom-strand C is at hap-coord 4.
1886 let mut h0 = MethylationTable::empty(7);
1887 h0.set_top(3, true);
1888 h0.set_bottom(4, true);
1889 let h1 = MethylationTable::empty(7);
1890 let cm = ContigMethylation::from_tables(vec![h0, h1]);
1891
1892 let mut buf = Vec::new();
1893 write_contig(&mut Cursor::new(&mut buf), "chr1", reference, &variants, &cm, 2).unwrap();
1894 let s = String::from_utf8(buf).unwrap();
1895 // Variant 0 (position 3, 1-based POS=4) owns the CpG via upstream-wins.
1896 // GT=1|0; hap0 carries the alt 'C', which is the top-C of the CpG.
1897 // MT for hap0 = top-strand bit at hap-coord 3 = 1. MB for hap0 = bottom
1898 // bit at hap-coord 4 = 1. Hap1 carries REF → MT/MB = '.'.
1899 assert!(
1900 s.contains("chr1\t4\t.\tT\tC\t.\t.\t.\tGT:MT:MB\t1|0:1|.:1|."),
1901 "expected variant 0 row, got:\n{s}"
1902 );
1903 // Variant 1 (position 4, 1-based POS=5) owns no CpGs (variant 0 won).
1904 // MT/MB = '.|.' for both haplotypes.
1905 assert!(
1906 s.contains("chr1\t5\t.\tT\tG\t.\t.\t.\tGT:MT:MB\t1|0:.|.:.|."),
1907 "expected variant 1 row, got:\n{s}"
1908 );
1909 }
1910}
1911
1912#[cfg(test)]
1913mod reader_error_tests {
1914 use super::*;
1915 use crate::vcf::genotype::{Genotype, VariantRecord};
1916
1917 /// Invalid UTF-8 in the VCF body must surface as `MalformedRecord`, not
1918 /// be silently coerced to an empty body (which would return an
1919 /// all-zeros methylation table — indistinguishable from a legitimately
1920 /// unmethylated contig).
1921 #[test]
1922 fn invalid_utf8_body_is_rejected() {
1923 let buf: &[u8] = &[0xFFu8, 0xFE, b'\n'];
1924 let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
1925 let msg = format!("{err}");
1926 assert!(matches!(err, ReadError::MalformedRecord { .. }), "got {err:?}");
1927 assert!(msg.contains("not valid UTF-8"), "unexpected message: {msg}");
1928 }
1929
1930 /// VCF POS is 1-based, so `0` is invalid and must surface as a clean
1931 /// `MalformedRecord` rather than a `u32` underflow inside the standalone
1932 /// reader (which does `pos_1based - 1`).
1933 #[test]
1934 fn standalone_record_with_pos_zero_is_rejected() {
1935 let buf = b"chr1\t0\t.\tC\t.\t.\t.\t.\tMT:MB\t0|0:0|0\n";
1936 let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
1937 let msg = format!("{err}");
1938 assert!(matches!(err, ReadError::MalformedRecord { .. }), "got {err:?}");
1939 assert!(msg.contains("POS must be 1-based"), "unexpected message: {msg}");
1940 }
1941
1942 #[test]
1943 fn malformed_record_with_too_few_columns_is_rejected() {
1944 // Six tab-separated fields, fewer than the required ten.
1945 let buf = b"chr1\t2\t.\tC\t.\t.\n";
1946 let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
1947 assert!(
1948 matches!(err, ReadError::MalformedRecord { .. }),
1949 "expected MalformedRecord, got {err:?}"
1950 );
1951 }
1952
1953 #[test]
1954 fn invalid_mtmb_character_in_standalone_record_is_rejected() {
1955 // MT contains '2' (not a valid 0/1/.).
1956 let buf = b"chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t2|0:0|0\n";
1957 let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
1958 assert!(
1959 matches!(err, ReadError::InvalidMtMbChar { ch: '2', .. }),
1960 "expected InvalidMtMbChar for '2', got {err:?}"
1961 );
1962 }
1963
1964 #[test]
1965 fn standalone_record_with_wrong_mt_entry_count_is_rejected() {
1966 // Ploidy=2 but MT carries 3 |-separated entries — extra entries
1967 // should not be silently truncated.
1968 let buf = b"chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t1|0|0:0|0\n";
1969 let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
1970 assert!(
1971 matches!(err, ReadError::PloidyEntryCountMismatch { actual: 3, expected: 2, .. }),
1972 "expected PloidyEntryCountMismatch{{actual:3,expected:2}}, got {err:?}"
1973 );
1974 }
1975
1976 #[test]
1977 fn standalone_record_with_too_few_mb_entries_is_rejected() {
1978 // Ploidy=2 but MB carries only 1 |-separated entry — missing
1979 // trailing entries should not be silently left untouched.
1980 let buf = b"chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t1|0:0\n";
1981 let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
1982 assert!(
1983 matches!(err, ReadError::PloidyEntryCountMismatch { actual: 1, expected: 2, .. }),
1984 "expected PloidyEntryCountMismatch{{actual:1,expected:2}}, got {err:?}"
1985 );
1986 }
1987
1988 #[test]
1989 fn variant_record_with_wrong_mt_entry_count_is_rejected() {
1990 use crate::vcf::genotype::{Genotype, VariantRecord};
1991 // Ploidy=2 but MT carries 3 |-separated entries on a variant row.
1992 let variants = vec![VariantRecord {
1993 position: 1,
1994 ref_allele: b"T".to_vec(),
1995 alt_alleles: vec![b"C".to_vec()],
1996 genotype: Genotype::parse("1|0").unwrap(),
1997 }];
1998 let buf = b"chr1\t2\t.\tT\tC\t.\t.\t.\tGT:MT:MB\t1|0:.|.|.:.|.\n";
1999 let err = read_contig_methylation(buf, "chr1", b"ATTT", &variants, 2).unwrap_err();
2000 assert!(
2001 matches!(err, ReadError::PloidyEntryCountMismatch { actual: 3, expected: 2, .. }),
2002 "expected PloidyEntryCountMismatch{{actual:3,expected:2}}, got {err:?}"
2003 );
2004 }
2005
2006 #[test]
2007 fn variant_record_with_too_few_mb_entries_is_rejected() {
2008 use crate::vcf::genotype::{Genotype, VariantRecord};
2009 let variants = vec![VariantRecord {
2010 position: 1,
2011 ref_allele: b"T".to_vec(),
2012 alt_alleles: vec![b"C".to_vec()],
2013 genotype: Genotype::parse("1|0").unwrap(),
2014 }];
2015 let buf = b"chr1\t2\t.\tT\tC\t.\t.\t.\tGT:MT:MB\t1|0:.|.:.\n";
2016 let err = read_contig_methylation(buf, "chr1", b"ATTT", &variants, 2).unwrap_err();
2017 assert!(
2018 matches!(err, ReadError::PloidyEntryCountMismatch { actual: 1, expected: 2, .. }),
2019 "expected PloidyEntryCountMismatch{{actual:1,expected:2}}, got {err:?}"
2020 );
2021 }
2022
2023 #[test]
2024 fn variant_record_mtmb_length_mismatch_is_rejected() {
2025 // ref ATTT, variant T->C at pos 1 (GT=1|0). Hap0's alt 'C' contains
2026 // zero CpGs, so the expected MT bitstring length on hap0 is 0; a
2027 // length-1 string ('1') here is a mismatch.
2028 let variants = vec![VariantRecord {
2029 position: 1,
2030 ref_allele: b"T".to_vec(),
2031 alt_alleles: vec![b"C".to_vec()],
2032 genotype: Genotype::parse("1|0").unwrap(),
2033 }];
2034 let buf = b"chr1\t2\t.\tT\tC\t.\t.\t.\tGT:MT:MB\t1|0:1|.:.|.\n";
2035 let err = read_contig_methylation(buf, "chr1", b"ATTT", &variants, 2).unwrap_err();
2036 assert!(
2037 matches!(err, ReadError::MtMbLengthMismatch { actual: 1, expected: 0, .. }),
2038 "expected MtMbLengthMismatch{{actual:1,expected:0}}, got {err:?}"
2039 );
2040 }
2041
2042 /// `"."` is a placeholder for "this haplotype owns zero CpGs at this
2043 /// variant". When the variants slice says the haplotype actually owns
2044 /// CpGs (e.g. the `1|0` hap on an `A → ACG` insertion), the parser must
2045 /// reject a `.` MT/MB entry rather than silently drop the truth bits.
2046 #[test]
2047 fn variant_record_dot_when_owned_cpgs_expected_is_rejected() {
2048 // ref AAAAAAAA + heterozygous `A → ACG` insertion at pos 1 on hap 0.
2049 // Hap 0's ALT allele inserts a `CG` dinucleotide → 1 owned CpG.
2050 // The malformed row uses `.` for hap 0 instead of `0` or `1`.
2051 let variants = vec![VariantRecord {
2052 position: 1,
2053 ref_allele: b"A".to_vec(),
2054 alt_alleles: vec![b"ACG".to_vec()],
2055 genotype: Genotype::parse("1|0").unwrap(),
2056 }];
2057 let buf = b"chr1\t2\t.\tA\tACG\t.\t.\t.\tGT:MT:MB\t1|0:.|.:.|.\n";
2058 let err = read_contig_methylation(buf, "chr1", b"AAAAAAAA", &variants, 2).unwrap_err();
2059 assert!(
2060 matches!(err, ReadError::MtMbLengthMismatch { actual: 0, expected: 1, .. }),
2061 "expected MtMbLengthMismatch{{actual:0,expected:1}}, got {err:?}"
2062 );
2063 }
2064
2065 /// Two variants share the same POS but live on different haplotypes
2066 /// (a decomposed multi-allelic site, GT `1|0` and `0|1`). Each one's
2067 /// alt allele introduces its own CpG on its own haplotype. The
2068 /// writer/reader index must disambiguate these by full `(POS, REF, ALT,
2069 /// GT)` identity, not by POS alone.
2070 ///
2071 /// Regression: keying `pos_to_vi` by POS alone made the reader resolve
2072 /// every row at that POS to the last-inserted variant index, then
2073 /// reject one of the two rows with `MtMbLengthMismatch` because the
2074 /// expected per-hap CpG count belonged to the other variant. The
2075 /// round-trip therefore failed altogether, and even when lengths
2076 /// happened to align, bits would have landed on the wrong haplotype.
2077 #[test]
2078 fn round_trip_disambiguates_two_variants_sharing_pos() {
2079 use crate::meth::{ContigMethylation, MethylationTable};
2080 use crate::vcf::genotype::Genotype;
2081 use std::io::Cursor;
2082 // Reference AAAAAAAA — no CpGs. Two phased insertions at pos 1 that
2083 // each plant a CpG on a different haplotype.
2084 let reference = b"AAAAAAAA".to_vec();
2085 let variants = vec![
2086 // Variant A: hap 0 gets the insertion `A → ACG` at pos 1.
2087 VariantRecord {
2088 position: 1,
2089 ref_allele: b"A".to_vec(),
2090 alt_alleles: vec![b"ACG".to_vec()],
2091 genotype: Genotype::parse("1|0").unwrap(),
2092 },
2093 // Variant B: hap 1 gets the insertion `A → ACG` at the SAME pos 1.
2094 VariantRecord {
2095 position: 1,
2096 ref_allele: b"A".to_vec(),
2097 alt_alleles: vec![b"ACG".to_vec()],
2098 genotype: Genotype::parse("0|1").unwrap(),
2099 },
2100 ];
2101
2102 // Per-hap methylation tables: hap 0 has its CpG (from variant A)
2103 // methylated on both strands at hap-coord 2 / 3 (alt span starts at
2104 // ref pos 1 → hap-coord 1, the inserted `CG` sits at hap-coords 2
2105 // and 3). Hap 1 has its CpG (from variant B) UNmethylated. The
2106 // asymmetry is what catches a wrong-variant route on read.
2107 let mut h0 = MethylationTable::with_len(reference.len() + 2);
2108 h0.set_top(2, true);
2109 h0.set_bottom(3, true);
2110 let h1 = MethylationTable::with_len(reference.len() + 2);
2111 let cm_in = ContigMethylation::from_tables(vec![h0, h1]);
2112
2113 let mut buf = Vec::new();
2114 write_contig(&mut Cursor::new(&mut buf), "chr1", &reference, &variants, &cm_in, 2).unwrap();
2115 let cm_out = read_contig_methylation(&buf, "chr1", &reference, &variants, 2)
2116 .expect("round-trip must succeed when records share POS but differ in GT");
2117
2118 // Hap 0: top + bottom bits of the inserted CpG must survive.
2119 assert!(cm_out.table_for(0).is_methylated(2, false), "hap0 top should be set");
2120 assert!(cm_out.table_for(0).is_methylated(3, true), "hap0 bottom should be set");
2121 // Hap 1: nothing methylated.
2122 assert!(!cm_out.table_for(1).is_methylated(2, false), "hap1 top must stay unset");
2123 assert!(!cm_out.table_for(1).is_methylated(3, true), "hap1 bottom must stay unset");
2124 }
2125
2126 /// Haploid samples must produce single-entry MT/MB strings on
2127 /// variant-free contigs too. Before sample-level ploidy resolution, a
2128 /// per-contig `unwrap_or(2)` fallback would default empty contigs to
2129 /// diploid and emit `0|0` instead of `0`, breaking round-trip with any
2130 /// contig that does have a haploid variant.
2131 #[test]
2132 fn haploid_variant_free_contig_writes_single_entry_mt_mb() {
2133 use crate::meth::{ContigMethylation, MethylationTable};
2134 use std::io::Cursor;
2135 // No variants → loader can't infer haploidy on its own; we pass
2136 // `sample_ploidy = 1` explicitly the way `run_simulation` /
2137 // `methylate::execute` would after the global resolution step.
2138 let reference = b"ACGT"; // single CpG at pos 1
2139 let cm = ContigMethylation::from_tables(vec![MethylationTable::with_len(4)]);
2140 let mut buf = Vec::new();
2141 write_contig(&mut Cursor::new(&mut buf), "chr1", reference, &[], &cm, 1).unwrap();
2142 let s = String::from_utf8(buf).unwrap();
2143 // One standalone row at POS 2 with a single-entry MT and MB field.
2144 assert!(s.contains("chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t0:0\n"), "got:\n{s}");
2145
2146 // Round-trip with sample_ploidy=1.
2147 let cm_out = read_contig_methylation(s.as_bytes(), "chr1", reference, &[], 1).unwrap();
2148 assert_eq!(cm_out.len(), 1, "haploid round-trip must yield 1 table");
2149 }
2150}
2151
2152#[cfg(test)]
2153mod record_probe_tests {
2154 use super::*;
2155 use std::io::Write as _;
2156
2157 fn write_temp_vcf(content: &str) -> tempfile::NamedTempFile {
2158 let mut f = tempfile::NamedTempFile::new().unwrap();
2159 f.write_all(content.as_bytes()).unwrap();
2160 f.flush().unwrap();
2161 f
2162 }
2163
2164 /// Header declares MT/MB AND a real record names them in FORMAT → true.
2165 #[test]
2166 fn header_and_record_with_mt_mb_returns_true() {
2167 let vcf = "##fileformat=VCFv4.4\n\
2168 ##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
2169 ##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
2170 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n\
2171 chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t0:0\n";
2172 let f = write_temp_vcf(vcf);
2173 assert!(vcf_has_mt_mb_records(f.path()).unwrap());
2174 }
2175
2176 /// Variant-style FORMAT (`GT:MT:MB`) also counts as a real record.
2177 #[test]
2178 fn variant_record_with_mt_mb_returns_true() {
2179 let vcf = "##fileformat=VCFv4.4\n\
2180 ##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
2181 ##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
2182 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n\
2183 chr1\t2\t.\tT\tC\t.\t.\t.\tGT:MT:MB\t1|0:1|.:1|.\n";
2184 let f = write_temp_vcf(vcf);
2185 assert!(vcf_has_mt_mb_records(f.path()).unwrap());
2186 }
2187
2188 /// Regression: header declares MT/MB but the file has NO records. This
2189 /// must be `false` so `simulate --methylation-mode` rejects it up front
2190 /// instead of hitting an internal-invariant failure later.
2191 #[test]
2192 fn header_only_no_records_returns_false() {
2193 let vcf = "##fileformat=VCFv4.4\n\
2194 ##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
2195 ##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
2196 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n";
2197 let f = write_temp_vcf(vcf);
2198 assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
2199 }
2200
2201 /// Header declares MT/MB but the only record carries a plain `GT` FORMAT
2202 /// (no methylation truth) → false.
2203 #[test]
2204 fn record_without_mt_mb_format_returns_false() {
2205 let vcf = "##fileformat=VCFv4.4\n\
2206 ##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
2207 ##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
2208 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n\
2209 chr1\t1\t.\tA\tT\t.\t.\t.\tGT\t0|1\n";
2210 let f = write_temp_vcf(vcf);
2211 assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
2212 }
2213
2214 #[test]
2215 fn header_with_only_mt_returns_false() {
2216 let vcf = "##fileformat=VCFv4.4\n\
2217 ##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
2218 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n\
2219 chr1\t2\t.\tC\t.\t.\t.\t.\tMT\t0\n";
2220 let f = write_temp_vcf(vcf);
2221 assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
2222 }
2223
2224 #[test]
2225 fn header_with_only_mb_returns_false() {
2226 let vcf = "##fileformat=VCFv4.4\n\
2227 ##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
2228 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n\
2229 chr1\t2\t.\tC\t.\t.\t.\t.\tMB\t0\n";
2230 let f = write_temp_vcf(vcf);
2231 assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
2232 }
2233
2234 #[test]
2235 fn header_with_neither_returns_false() {
2236 let vcf = "##fileformat=VCFv4.4\n\
2237 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n";
2238 let f = write_temp_vcf(vcf);
2239 assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
2240 }
2241
2242 /// When the header lacks MT/MB, the probe must early-exit at the
2243 /// `#CHROM` line and never decode the data body. Pin that by appending
2244 /// invalid UTF-8 after the column header: a body-reading implementation
2245 /// would fail to decode and surface an `InvalidData` I/O error.
2246 #[test]
2247 fn no_mt_mb_header_does_not_read_body() {
2248 let mut f = tempfile::NamedTempFile::new().unwrap();
2249 f.write_all(
2250 b"##fileformat=VCFv4.4\n\
2251 #CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n",
2252 )
2253 .unwrap();
2254 // Invalid UTF-8 (lone 0xFF bytes) where the data body would be.
2255 f.write_all(&[0xFFu8; 64]).unwrap();
2256 f.flush().unwrap();
2257 // Must return false without erroring: the header lacked MT/MB, so we
2258 // bail at the `#CHROM` line before touching the 0xFF block.
2259 assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
2260 }
2261}