Skip to main content

holodeck_lib/output/
methylation_bedgraph.rs

1//! Population-fraction methylation bedGraph in MethylDackel `extract` format.
2//!
3//! Derived closed-form from the per-haplotype methylation bitmap — no
4//! simulated reads. For each reference CpG, aggregates over
5//! (haplotype × strand) presence/absence and emits
6//! `rate = round(100 * n_methylated / (n_methylated + n_unmethylated))`
7//! as an integer percentage (matching MethylDackel `extract`).
8//!
9//! This is intentionally distinct from `simulate`'s `--cpg-truth-bedgraph`,
10//! which is coverage-weighted (depends on simulated read counts).
11
12use std::io::Write;
13
14use anyhow::{Result, ensure};
15
16use crate::haplotype::Haplotype;
17use crate::meth::ContigMethylation;
18use crate::sequence_dict::SequenceDictionary;
19
20/// Write the `track` header line for a MethylDackel-format population-fraction
21/// bedGraph.
22///
23/// Call once before streaming per-contig records with
24/// [`write_bedgraph_records`].
25///
26/// # Errors
27///
28/// Returns an error if writing to `writer` fails.
29pub fn write_bedgraph_header<W: Write>(writer: &mut W) -> Result<()> {
30    writeln!(
31        writer,
32        "track type=\"bedGraph\" description=\"holodeck methylate population fraction\""
33    )?;
34    Ok(())
35}
36
37/// Write per-CpG bedGraph records for a single contig, without the track
38/// header line.
39///
40/// For each reference CpG (top-strand `CG` dinucleotide), aggregates over all
41/// haplotype × strand combinations whose haplotype actually *preserves* that
42/// CpG:
43/// - `n_methylated` — count of (haplotype, strand) entries whose methylation
44///   bit is set at this site.
45/// - `n_unmethylated` — preserving haplotype entries that are not methylated.
46/// - `rate` — `round(100 * n_methylated / (n_methylated + n_unmethylated))`,
47///   an integer percentage matching MethylDackel `extract`.
48///
49/// Reference CpGs destroyed on every haplotype (SNP at the C or G, indel
50/// straddling either base) are omitted entirely — `n_meth = n_unmeth = 0`
51/// makes the rate undefined, not zero. Variants that only shift downstream
52/// coordinates are handled by mapping each ref position through that
53/// haplotype's [`Haplotype::extract_fragment`] / `hap_start` before indexing
54/// the haplotype-coordinate methylation bitmap.
55///
56/// `haplotypes` must be either empty (variant-free contig — every ref CpG
57/// is queried at its reference coordinates directly) or carry exactly one
58/// entry per haplotype tracked by `methylation`, in the same order. A
59/// length mismatch is a programming error from a caller that built the
60/// methylation table and the haplotypes slice from inconsistent inputs;
61/// returning an error here surfaces that mismatch instead of silently
62/// reading reference offsets out of haplotype-coordinate bitmaps.
63///
64/// The `chrom` start and end columns use 0-based half-open coordinates
65/// matching MethylDackel's `extract` output: `start = top-C ref position`,
66/// `end = start + 1`.
67///
68/// # Errors
69///
70/// Returns an error if writing to `writer` fails, or if `haplotypes` is
71/// non-empty and its length differs from `methylation.len()`.
72pub fn write_bedgraph_records<W: Write>(
73    writer: &mut W,
74    chrom: &str,
75    reference: &[u8],
76    haplotypes: &[Haplotype],
77    methylation: &ContigMethylation,
78) -> Result<()> {
79    ensure!(
80        haplotypes.is_empty() || haplotypes.len() == methylation.len(),
81        "haplotypes length ({}) must equal methylation haplotype count ({}) \
82         on contig {chrom:?}",
83        haplotypes.len(),
84        methylation.len(),
85    );
86    if reference.len() < 2 {
87        return Ok(());
88    }
89
90    // Aggregate per reference CpG. `BTreeMap` so emit order is the sorted
91    // top-C reference position, matching MethylDackel's output and the
92    // previous loop's natural order. Each value is `(n_meth, n_unmeth)`.
93    let mut by_ref_pos: std::collections::BTreeMap<u32, (u32, u32)> =
94        std::collections::BTreeMap::new();
95
96    if haplotypes.is_empty() {
97        // No variants → ref == hap. Walk the reference once.
98        for i in 0..reference.len() - 1 {
99            if !reference[i].eq_ignore_ascii_case(&b'C')
100                || !reference[i + 1].eq_ignore_ascii_case(&b'G')
101            {
102                continue;
103            }
104            #[expect(clippy::cast_possible_truncation, reason = "ref pos fits u32")]
105            let top_c = i as u32;
106            let entry = by_ref_pos.entry(top_c).or_insert((0, 0));
107            for hap_idx in 0..methylation.len() {
108                let table = methylation.table_for(hap_idx);
109                if table.is_methylated(top_c, false) {
110                    entry.0 += 1;
111                } else {
112                    entry.1 += 1;
113                }
114                if table.is_methylated(top_c + 1, true) {
115                    entry.0 += 1;
116                } else {
117                    entry.1 += 1;
118                }
119            }
120        }
121    } else {
122        // Variants present → materialize each haplotype once, walk its
123        // bases looking for CpGs, map each haplotype CpG back to a ref
124        // position via the per-base `ref_positions` returned by
125        // `extract_fragment`. The per-CpG `extract_fragment(..., 2)` call
126        // we used to do per ref CpG × haplotype is gone; this is O(L + V)
127        // per haplotype instead of O(C × log V).
128        for (hap_idx, hap) in haplotypes.iter().enumerate() {
129            #[expect(clippy::cast_possible_truncation, reason = "ref length fits u32")]
130            let hap_len = hap.hap_position_for(reference.len() as u32) as usize;
131            let (hap_bases, ref_positions, _hap_start) =
132                hap.extract_fragment(reference, 0, hap_len);
133            if hap_bases.len() < 2 {
134                continue;
135            }
136            let table = methylation.table_for(hap_idx);
137            for h in 0..hap_bases.len() - 1 {
138                if !hap_bases[h].eq_ignore_ascii_case(&b'C')
139                    || !hap_bases[h + 1].eq_ignore_ascii_case(&b'G')
140                {
141                    continue;
142                }
143                // Only count haplotype CpGs that correspond to a true ref
144                // CpG at adjacent ref positions. A CpG formed across an
145                // insertion (`ref_positions[h+1] == ref_positions[h]`) has
146                // no reference coordinate to attribute it to — MethylDackel
147                // wouldn't see it either, and we want output parity.
148                let ref_top_c = ref_positions[h];
149                let ref_bot_c = ref_positions[h + 1];
150                if ref_bot_c != ref_top_c + 1 {
151                    continue;
152                }
153                let ref_top_idx = ref_top_c as usize;
154                let ref_bot_idx = ref_bot_c as usize;
155                if ref_bot_idx >= reference.len()
156                    || !reference[ref_top_idx].eq_ignore_ascii_case(&b'C')
157                    || !reference[ref_bot_idx].eq_ignore_ascii_case(&b'G')
158                {
159                    continue;
160                }
161                #[expect(clippy::cast_possible_truncation, reason = "hap pos fits u32")]
162                let hap_top = h as u32;
163                let entry = by_ref_pos.entry(ref_top_c).or_insert((0, 0));
164                if table.is_methylated(hap_top, false) {
165                    entry.0 += 1;
166                } else {
167                    entry.1 += 1;
168                }
169                if table.is_methylated(hap_top + 1, true) {
170                    entry.0 += 1;
171                } else {
172                    entry.1 += 1;
173                }
174            }
175        }
176    }
177
178    for (top_c, (n_meth, n_unmeth)) in by_ref_pos {
179        let denom = n_meth + n_unmeth;
180        if denom == 0 {
181            continue;
182        }
183        // MethylDackel's `extract` reports the rate as an integer percentage;
184        // round to match so this output and `simulate --cpg-truth-bedgraph`
185        // (which rounds identically) can be compared by the same downstream
186        // tooling.
187        let rate = (f64::from(n_meth) / f64::from(denom) * 100.0).round();
188        #[expect(clippy::cast_possible_truncation, reason = "rate is in [0, 100]")]
189        #[expect(clippy::cast_sign_loss, reason = "rate is non-negative")]
190        let rate = rate as u32;
191        writeln!(writer, "{chrom}\t{top_c}\t{}\t{rate}\t{n_meth}\t{n_unmeth}", top_c + 1)?;
192    }
193    Ok(())
194}
195
196/// Write a complete population-fraction bedGraph — header line followed by
197/// per-CpG records for every contig in `per_contig`.
198///
199/// Each `per_contig` entry is `(contig_name, ContigMethylation, reference,
200/// haplotypes_for_this_contig)`. The `haplotypes` slice must be the same
201/// per-haplotype layout used to build `ContigMethylation` so per-haplotype
202/// ref→hap coordinate mappings line up; pass an empty slice for variant-free
203/// contigs.
204///
205/// `dict` is accepted for future contig-order validation but is not used yet.
206///
207/// # Errors
208///
209/// Returns an error if writing to `writer` fails.
210pub fn write_bedgraph<W: Write>(
211    writer: &mut W,
212    dict: &SequenceDictionary,
213    per_contig: &[(String, ContigMethylation, Vec<u8>, Vec<Haplotype>)],
214) -> Result<()> {
215    let _ = dict; // reserved for future contig-order validation
216    write_bedgraph_header(writer)?;
217    for (chrom, cm, reference, haplotypes) in per_contig {
218        write_bedgraph_records(writer, chrom, reference, haplotypes, cm)?;
219    }
220    Ok(())
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::haplotype::{Haplotype, build_haplotypes};
227    use crate::meth::{ContigMethylation, MethylationTable};
228    use crate::sequence_dict::{SequenceDictionary, SequenceMetadata};
229    use crate::vcf::genotype::{Genotype, VariantRecord};
230
231    /// Methylation state for one haplotype at the single CpG in the `ACGT`
232    /// test reference (top-C at pos 1, bottom-C at pos 2).
233    #[derive(Clone, Copy)]
234    struct HapMethState {
235        top: bool,
236        bottom: bool,
237    }
238
239    /// Build a diploid [`ContigMethylation`] for the reference `ACGT`, which
240    /// has a single CpG: top-C at position 1, bottom-C at position 2.
241    fn build_diploid_at_one_cpg(
242        hap0: HapMethState,
243        hap1: HapMethState,
244    ) -> (Vec<u8>, ContigMethylation) {
245        // Reference ACGT: CpG at top-C pos 1 and bottom-C pos 2.
246        let reference = b"ACGT".to_vec();
247        let mut h0 = MethylationTable::with_len(4);
248        if hap0.top {
249            h0.set_top(1, true);
250        }
251        if hap0.bottom {
252            h0.set_bottom(2, true);
253        }
254        let mut h1 = MethylationTable::with_len(4);
255        if hap1.top {
256            h1.set_top(1, true);
257        }
258        if hap1.bottom {
259            h1.set_bottom(2, true);
260        }
261        let cm = ContigMethylation::from_tables(vec![h0, h1]);
262        (reference, cm)
263    }
264
265    /// Build a minimal [`SequenceDictionary`] for a single contig.
266    fn single_contig_dict(name: &str, len: usize) -> SequenceDictionary {
267        let entry = SequenceMetadata::new(0, name.to_string(), len);
268        SequenceDictionary::from_entries(vec![entry])
269    }
270
271    #[test]
272    fn population_fraction_bedgraph_diploid_all_methylated() {
273        let (reference, cm) = build_diploid_at_one_cpg(
274            HapMethState { top: true, bottom: true },
275            HapMethState { top: true, bottom: true },
276        );
277        let dict = single_contig_dict("chr1", reference.len());
278        let mut buf = Vec::new();
279        write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
280            .unwrap();
281        let s = String::from_utf8(buf).unwrap();
282        // Track header must be present.
283        assert!(s.starts_with("track type="), "missing track header: {s}");
284        // One CpG record: start=1, end=2, rate=100, n_meth=4, n_unmeth=0.
285        assert!(s.contains("chr1\t1\t2\t100"), "expected rate 100: {s}");
286        assert!(s.contains("4\t0"), "expected n_meth=4 n_unmeth=0: {s}");
287    }
288
289    #[test]
290    fn population_fraction_bedgraph_diploid_hemi() {
291        // Only haplotype-0 top strand is methylated → n_meth=1 out of 4.
292        let (reference, cm) = build_diploid_at_one_cpg(
293            HapMethState { top: true, bottom: false },
294            HapMethState { top: false, bottom: false },
295        );
296        let dict = single_contig_dict("chr1", reference.len());
297        let mut buf = Vec::new();
298        write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
299            .unwrap();
300        let s = String::from_utf8(buf).unwrap();
301        // rate = 100 * 1/4 = 25.
302        assert!(s.contains("chr1\t1\t2\t25"), "expected rate 25: {s}");
303        assert!(s.contains("1\t3"), "expected n_meth=1 n_unmeth=3: {s}");
304    }
305
306    #[test]
307    fn population_fraction_bedgraph_no_cpg_emits_only_header() {
308        // Reference with no CpG: "AATT".
309        let reference = b"AATT".to_vec();
310        let h0 = MethylationTable::with_len(4);
311        let cm = ContigMethylation::from_tables(vec![h0]);
312        let dict = single_contig_dict("chr1", reference.len());
313        let mut buf = Vec::new();
314        write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
315            .unwrap();
316        let s = String::from_utf8(buf).unwrap();
317        assert!(s.starts_with("track type="), "missing track header: {s}");
318        // No data lines.
319        let data_lines: Vec<&str> = s.lines().filter(|l| !l.starts_with("track")).collect();
320        assert!(data_lines.is_empty(), "unexpected data lines: {s}");
321    }
322
323    #[test]
324    fn population_fraction_bedgraph_multiple_cpgs() {
325        // Reference ACGTACG has two CpGs: top-C at pos 1 and pos 5.
326        // Diploid, all four (hap × strand) bits set for both sites.
327        let reference = b"ACGTACG".to_vec();
328        let mut h0 = MethylationTable::with_len(7);
329        h0.set_top(1, true);
330        h0.set_bottom(2, true);
331        h0.set_top(5, true);
332        h0.set_bottom(6, true);
333        let mut h1 = MethylationTable::with_len(7);
334        h1.set_top(1, true);
335        h1.set_bottom(2, true);
336        h1.set_top(5, true);
337        h1.set_bottom(6, true);
338        let cm = ContigMethylation::from_tables(vec![h0, h1]);
339        let dict = single_contig_dict("chr1", reference.len());
340        let mut buf = Vec::new();
341        write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
342            .unwrap();
343        let s = String::from_utf8(buf).unwrap();
344        assert!(s.contains("chr1\t1\t2\t100"), "missing first CpG: {s}");
345        assert!(s.contains("chr1\t5\t6\t100"), "missing second CpG: {s}");
346    }
347
348    #[test]
349    fn population_fraction_bedgraph_rounds_non_integer_rate() {
350        // Triploid reference ACGT (one CpG, top-C at 1, bottom-C at 2) gives
351        // 6 (haplotype × strand) entries. Set 2 of them methylated → 2/6 =
352        // 33.33%, which MethylDackel reports as the integer `33`. This pins
353        // the rounding: an unrounded f64 would print `33.33333333333333`.
354        let reference = b"ACGT".to_vec();
355        let mut h0 = MethylationTable::with_len(4);
356        h0.set_top(1, true);
357        let mut h1 = MethylationTable::with_len(4);
358        h1.set_top(1, true);
359        let h2 = MethylationTable::with_len(4);
360        let cm = ContigMethylation::from_tables(vec![h0, h1, h2]);
361        let dict = single_contig_dict("chr1", reference.len());
362        let mut buf = Vec::new();
363        write_bedgraph(&mut buf, &dict, &[("chr1".to_string(), cm, reference, Vec::new())])
364            .unwrap();
365        let s = String::from_utf8(buf).unwrap();
366        // rate = round(100 * 2/6) = 33; n_meth=2, n_unmeth=4.
367        assert!(s.contains("chr1\t1\t2\t33\t2\t4"), "expected rounded rate 33: {s}");
368        assert!(!s.contains("33.3"), "rate must be an integer, not a float: {s}");
369    }
370
371    #[test]
372    fn write_bedgraph_records_streaming_matches_write_bedgraph() {
373        // Verify that write_bedgraph_header + write_bedgraph_records produces
374        // the same output as write_bedgraph.
375        let (reference, cm) = build_diploid_at_one_cpg(
376            HapMethState { top: true, bottom: false },
377            HapMethState { top: true, bottom: true },
378        );
379        let dict = single_contig_dict("chr1", reference.len());
380
381        let mut all_at_once = Vec::new();
382        write_bedgraph(
383            &mut all_at_once,
384            &dict,
385            &[("chr1".to_string(), cm.clone(), reference.clone(), Vec::new())],
386        )
387        .unwrap();
388
389        let mut streamed = Vec::new();
390        write_bedgraph_header(&mut streamed).unwrap();
391        let no_haps: Vec<Haplotype> = Vec::new();
392        write_bedgraph_records(&mut streamed, "chr1", &reference, &no_haps, &cm).unwrap();
393
394        assert_eq!(all_at_once, streamed, "streaming API must match batch API");
395    }
396
397    /// Insertion variant: a CpG downstream of an indel must be read from the
398    /// haplotype-shifted coordinate, not from its raw reference offset.
399    ///
400    /// Regression: before this fix, `write_bedgraph_records` indexed
401    /// `ContigMethylation` (haplotype-coord) with reference indices, so the
402    /// methylated CpG on the inserted haplotype was missed entirely and the
403    /// emitted rate was 0% instead of 50%.
404    #[test]
405    fn population_fraction_bedgraph_respects_haplotype_coordinates_for_indel() {
406        // Reference: AACGAA. The single CpG is at ref pos 2 (top-C) / 3 (G).
407        // Heterozygous insertion at pos 1 on hap1 turns the A at pos 1 into
408        // "AAA", shifting downstream positions by +2. On hap1 the CpG lives
409        // at hap-coords 4 / 5.
410        let reference = b"AACGAA".to_vec();
411        let variants = vec![VariantRecord {
412            position: 1,
413            ref_allele: b"A".to_vec(),
414            alt_alleles: vec![b"AAA".to_vec()],
415            genotype: Genotype::parse("0|1").unwrap(),
416        }];
417        let haplotypes = build_haplotypes(&variants, 2, &mut rand::rng());
418        assert_eq!(haplotypes.len(), 2);
419        // Hap0 has no variants → length matches reference. Its CpG is
420        // unmethylated (default-zero bits).
421        // Hap1 has the +2 insertion → length = 8. Its CpG is methylated on
422        // both strands at the shifted haplotype coordinates.
423        let h0 = MethylationTable::with_len(reference.len());
424        let mut h1 = MethylationTable::with_len(reference.len() + 2);
425        h1.set_top(4, true);
426        h1.set_bottom(5, true);
427        let cm = ContigMethylation::from_tables(vec![h0, h1]);
428
429        let mut buf = Vec::new();
430        write_bedgraph_header(&mut buf).unwrap();
431        write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm).unwrap();
432        let s = String::from_utf8(buf).unwrap();
433
434        // Expected: hap0 contributes 0+2 unmethylated, hap1 contributes 2+0
435        // methylated. Rate = 100 * 2 / 4 = 50.
436        assert!(s.contains("chr1\t2\t3\t50\t2\t2"), "expected rate 50, counts 2/2: {s}");
437    }
438
439    /// SNP that destroys the CpG on a haplotype: that haplotype is excluded
440    /// from the denominator entirely instead of being miscounted as
441    /// unmethylated.
442    #[test]
443    fn population_fraction_bedgraph_drops_haplotypes_with_destroyed_cpg() {
444        // Reference: ACGT, single CpG at pos 1/2. Het SNP at pos 1 (C→T)
445        // destroys the CpG on hap1.
446        let reference = b"ACGT".to_vec();
447        let variants = vec![VariantRecord {
448            position: 1,
449            ref_allele: b"C".to_vec(),
450            alt_alleles: vec![b"T".to_vec()],
451            genotype: Genotype::parse("0|1").unwrap(),
452        }];
453        let haplotypes = build_haplotypes(&variants, 2, &mut rand::rng());
454        let mut h0 = MethylationTable::with_len(reference.len());
455        let h1 = MethylationTable::with_len(reference.len());
456        // Hap0 carries the reference CpG and is fully methylated.
457        h0.set_top(1, true);
458        h0.set_bottom(2, true);
459        let cm = ContigMethylation::from_tables(vec![h0, h1]);
460
461        let mut buf = Vec::new();
462        write_bedgraph_header(&mut buf).unwrap();
463        write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm).unwrap();
464        let s = String::from_utf8(buf).unwrap();
465
466        // Only hap0 preserves the CpG and is fully methylated → rate=100,
467        // counts 2/0 (NOT 2/2 as the old reference-index-only writer would
468        // have produced by counting hap1's destroyed CpG as unmethylated).
469        assert!(s.contains("chr1\t1\t2\t100\t2\t0"), "expected rate 100, counts 2/0: {s}");
470    }
471
472    /// Length mismatch between `haplotypes` and the methylation table is a
473    /// caller bug — surface it as an error rather than silently reading
474    /// haplotype-coordinate bitmaps with reference offsets.
475    #[test]
476    fn population_fraction_bedgraph_errors_when_haplotypes_len_mismatches_methylation() {
477        let reference = b"ACGT".to_vec();
478        // methylation says 2 haplotypes…
479        let cm = ContigMethylation::from_tables(vec![
480            MethylationTable::with_len(reference.len()),
481            MethylationTable::with_len(reference.len()),
482        ]);
483        // …but caller passes only 1 haplotype.
484        let haplotypes = build_haplotypes(&[], 1, &mut rand::rng());
485        assert_eq!(haplotypes.len(), 1);
486
487        let mut buf = Vec::new();
488        let err = write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm)
489            .expect_err("length mismatch should error");
490        let msg = format!("{err}");
491        assert!(msg.contains("haplotypes length"), "unexpected error message: {msg}");
492        assert!(msg.contains("methylation haplotype count"), "unexpected error: {msg}");
493    }
494
495    /// CpG destroyed on every haplotype: emit no row at all (denominator 0
496    /// means the population rate is undefined, not zero).
497    #[test]
498    fn population_fraction_bedgraph_skips_cpg_destroyed_on_every_haplotype() {
499        // Reference: ACGT, single CpG. Hom-alt SNP destroys it on both haps.
500        let reference = b"ACGT".to_vec();
501        let variants = vec![VariantRecord {
502            position: 1,
503            ref_allele: b"C".to_vec(),
504            alt_alleles: vec![b"T".to_vec()],
505            genotype: Genotype::parse("1|1").unwrap(),
506        }];
507        let haplotypes = build_haplotypes(&variants, 2, &mut rand::rng());
508        let h0 = MethylationTable::with_len(reference.len());
509        let h1 = MethylationTable::with_len(reference.len());
510        let cm = ContigMethylation::from_tables(vec![h0, h1]);
511
512        let mut buf = Vec::new();
513        write_bedgraph_header(&mut buf).unwrap();
514        write_bedgraph_records(&mut buf, "chr1", &reference, &haplotypes, &cm).unwrap();
515        let s = String::from_utf8(buf).unwrap();
516
517        let data_lines: Vec<&str> = s.lines().filter(|l| !l.starts_with("track")).collect();
518        assert!(data_lines.is_empty(), "expected no data lines, got: {s}");
519    }
520}