Skip to main content

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}