Skip to main content

holodeck_lib/output/
cpg_truth.rs

1//! Per-CpG ground-truth methylation tally and bedGraph writer.
2//!
3//! Counts, per reference CpG site, how many simulated read mates "called"
4//! the site as methylated vs unmethylated based on the simulator's
5//! per-haplotype, per-strand methylation bitmap. The output format matches
6//! [MethylDackel]'s `extract` CpG `.bedGraph` exactly:
7//!
8//! ```text
9//! track type="bedGraph" description="..."
10//! chrom  start  end  rate(0-100)  n_methylated  n_unmethylated
11//! ```
12//!
13//! so the same downstream concordance scripts that consume MethylDackel
14//! output (e.g. comparing aligner-derived methylation calls to truth) can
15//! be pointed at the truth bedGraph without modification.
16//!
17//! [MethylDackel]: https://github.com/dpryan79/MethylDackel
18//!
19//! # Counting model
20//!
21//! For each simulated read mate (R1 and R2 counted independently — matching
22//! MethylDackel's default per-read tallying):
23//!
24//! - For every reference CpG (top-strand `CG` dinucleotide at top positions
25//!   `(p, p+1)`) whose strand-specific C is covered by the mate's genomic
26//!   span:
27//!     - **CT (top-strand-derived) fragments** carry top-strand chemistry;
28//!       the call is `top[hap_pos_for(p)]`.
29//!     - **GA (bottom-strand-derived) fragments** carry bottom-strand
30//!       chemistry; the call is `bottom[hap_pos_for(p+1)]`.
31//! - The call increments `n_methylated` (bit set) or `n_unmethylated` (bit
32//!   clear).
33//!
34//! Both R1 and R2 of a directional pair derive from the same source-strand
35//! chemistry. When both mates cover the same CpG site (the genomic overlap
36//! of the pair), they make the same call (since chemistry is now applied
37//! once at fragment scale) and both contribute.
38//!
39//! Calls are made against the per-haplotype methylation bitmap, not the
40//! post-conversion sequenced base. So the truth reflects the simulator's
41//! biological intent and is unaffected by `--methylation-conversion-rate`
42//! (chemistry inefficiency) or sequencing errors — useful precisely because
43//! it lets a downstream evaluator measure how much of the alignment-tool
44//! disagreement is due to chemistry/error noise vs alignment failure.
45
46use std::collections::HashMap;
47use std::fs::File;
48use std::io::{BufWriter, Write};
49use std::path::Path;
50
51use anyhow::{Context, Result};
52
53use crate::haplotype::Haplotype;
54use crate::meth::ContigMethylation;
55use crate::sequence_dict::SequenceDictionary;
56
57/// Per-(contig, top-C reference position) tally of methylated vs unmethylated
58/// calls. Indexed by `(contig_index, ref_pos_of_top_C)`; the implied bedGraph
59/// `end` is `ref_pos + 1` (matches MethylDackel).
60#[derive(Debug, Default)]
61pub struct CpgTruthTally {
62    counts: HashMap<(usize, u32), (u32, u32)>,
63}
64
65impl CpgTruthTally {
66    /// Empty tally.
67    #[must_use]
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Number of distinct CpG sites with at least one call. Test-only.
73    #[cfg(test)]
74    #[must_use]
75    pub(crate) fn len(&self) -> usize {
76        self.counts.len()
77    }
78
79    /// Look up the (n_methylated, n_unmethylated) tuple for a given site.
80    /// Test-only.
81    #[cfg(test)]
82    #[must_use]
83    pub(crate) fn get(&self, contig_idx: usize, ref_pos: u32) -> Option<(u32, u32)> {
84        self.counts.get(&(contig_idx, ref_pos)).copied()
85    }
86
87    /// Record one mate's contribution. `mate_ref_positions` is the per-base
88    /// reference position list for the mate's GENOMIC portion (no adapter),
89    /// in ascending reference order. `ref_cpgs` is the precomputed sorted
90    /// list of top-C reference CpG positions for this contig (caller pre-
91    /// computes once per contig). `is_forward_fragment` distinguishes CT
92    /// (top-strand-derived) from GA (bottom-strand-derived) fragments.
93    ///
94    /// For CT fragments the call is read off `top[hap_position_for(p)]`;
95    /// for GA fragments off `bottom[hap_position_for(p+1)]`. Both look up
96    /// against the haplotype the fragment was sampled from. The bitmap is
97    /// indexed by haplotype coordinates, so we map each reference CpG
98    /// position through `Haplotype::hap_position_for`.
99    ///
100    /// # Panics
101    ///
102    /// Panics if `mate_ref_positions.last()` is unreachable after the
103    /// non-empty check (it cannot be — the early return covers that case).
104    #[allow(clippy::too_many_arguments)]
105    pub fn record_mate(
106        &mut self,
107        contig_idx: usize,
108        mate_ref_positions: &[u32],
109        ref_cpgs: &[u32],
110        methylation: &ContigMethylation,
111        haplotype: &Haplotype,
112        haplotype_index: usize,
113        is_forward_fragment: bool,
114    ) {
115        if mate_ref_positions.is_empty() || ref_cpgs.is_empty() {
116            return;
117        }
118        // Mate genomic span [min, max] in reference coordinates. fragment
119        // ref_positions are ascending and may include duplicates (insertions
120        // map multiple read bases to one ref pos) — first/last are still
121        // the bounding values.
122        let mate_min = mate_ref_positions[0];
123        let mate_max = *mate_ref_positions.last().unwrap();
124
125        // Covered C reference position depends on which strand carries the
126        // chemistry: CT → top-C at p; GA → bottom-C at p+1.
127        // The CpG site "starts" at p (top-C) regardless of which strand
128        // carried the call — that's how MethylDackel groups per-CpG calls.
129        let table = methylation.table_for(haplotype_index);
130
131        // Walk the ref CpGs in the mate's range. Use binary search to bound.
132        let lo = ref_cpgs.partition_point(|&p| p < mate_min);
133        let hi = ref_cpgs.partition_point(|&p| p <= mate_max);
134        for &site_p in &ref_cpgs[lo..hi] {
135            let covered_pos = if is_forward_fragment { site_p } else { site_p + 1 };
136            // The strand-specific C must lie within the mate's genomic span.
137            // (For CT fragments at site_p in [mate_min, mate_max] this is
138            // always true; for GA fragments site_p+1 may fall just past
139            // mate_max if the CpG sits at the right edge.)
140            if covered_pos < mate_min || covered_pos > mate_max {
141                continue;
142            }
143            // Skip CpGs that fall in deletion gaps on this haplotype: a
144            // deleted reference position has no haplotype base, so the read
145            // at this fragment doesn't actually carry that C. We can detect
146            // this by looking up the position in the mate's ref_positions —
147            // if it's missing, the C was deleted out.
148            if mate_ref_positions.binary_search(&covered_pos).is_err() {
149                continue;
150            }
151            let hap_pos = haplotype.hap_position_for(covered_pos);
152            let is_meth = table.is_methylated(hap_pos, !is_forward_fragment);
153            let entry = self.counts.entry((contig_idx, site_p)).or_insert((0, 0));
154            if is_meth {
155                entry.0 = entry.0.saturating_add(1);
156            } else {
157                entry.1 = entry.1.saturating_add(1);
158            }
159        }
160    }
161
162    /// Write the tally to `path` as a MethylDackel-format CpG bedGraph.
163    ///
164    /// Format (tab-separated): `chrom  start  end  rate  n_meth  n_unmeth`,
165    /// preceded by a `track` header line. Sites are emitted in (contig
166    /// index, reference position) order. `rate` is the integer percentage
167    /// `round(100 * n_meth / (n_meth + n_unmeth))`. Sites with zero total
168    /// coverage are skipped (they would not appear in MethylDackel's output
169    /// either).
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if the file cannot be created, if a contig index
174    /// in the tally is missing from `dict`, or if any I/O write fails.
175    pub fn write_bedgraph(&self, dict: &SequenceDictionary, path: &Path) -> Result<()> {
176        let file = File::create(path)
177            .with_context(|| format!("creating CpG truth bedGraph: {}", path.display()))?;
178        let mut w = BufWriter::new(file);
179        writeln!(
180            w,
181            "track type=\"bedGraph\" description=\"holodeck CpG truth (per-haplotype methylation calls per simulated read)\""
182        )?;
183        let mut sorted: Vec<((usize, u32), (u32, u32))> =
184            self.counts.iter().map(|(k, v)| (*k, *v)).collect();
185        sorted.sort_by_key(|&(k, _)| k);
186        for ((contig_idx, p), (n_meth, n_unmeth)) in sorted {
187            let total = n_meth + n_unmeth;
188            if total == 0 {
189                continue;
190            }
191            let rate = ((f64::from(n_meth) / f64::from(total)) * 100.0).round();
192            #[expect(clippy::cast_possible_truncation, reason = "rate is in [0, 100]")]
193            #[expect(clippy::cast_sign_loss, reason = "rate is non-negative")]
194            let rate = rate as u32;
195            let contig_name = dict
196                .get_by_index(contig_idx)
197                .with_context(|| format!("missing contig index {contig_idx} in dictionary"))?
198                .name();
199            writeln!(w, "{contig_name}\t{p}\t{}\t{rate}\t{n_meth}\t{n_unmeth}", p + 1)?;
200        }
201        w.flush()?;
202        Ok(())
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use rand::SeedableRng;
209    use rand::rngs::SmallRng;
210
211    use super::*;
212    use crate::haplotype::build_haplotypes;
213    use crate::meth::{ContigMethylation, MethylationTable};
214
215    /// Build a single-haplotype `ContigMethylation` with a manually-set
216    /// bitmap, for tests that need precise control over the methylation
217    /// state without depending on `from_haplotypes`'s RNG.
218    fn cm_with_top(top_meth_positions: &[u32], len: usize) -> ContigMethylation {
219        let mut table = MethylationTable::empty(len);
220        for &p in top_meth_positions {
221            table.set_top(p as usize, true);
222        }
223        ContigMethylation::from_tables(vec![table])
224    }
225
226    fn cm_with_bottom(bottom_meth_positions: &[u32], len: usize) -> ContigMethylation {
227        let mut table = MethylationTable::empty(len);
228        for &p in bottom_meth_positions {
229            table.set_bottom(p as usize, true);
230        }
231        ContigMethylation::from_tables(vec![table])
232    }
233
234    /// All-reference haplotype (no variants) so haplotype coords == reference coords.
235    fn ref_haplotype() -> crate::haplotype::Haplotype {
236        let haps = build_haplotypes(&[], 1, &mut SmallRng::seed_from_u64(0));
237        haps.into_iter().next().unwrap()
238    }
239
240    #[test]
241    fn test_record_mate_ct_fragment_methylated_top() {
242        // Reference: ACGTACG (CpGs at 1, 5). Mate covers positions [0, 6].
243        // CT fragment: tally top[hap_pos] at sites 1 and 5.
244        // Bitmap: top[1]=true, top[5]=false.
245        let cm = cm_with_top(&[1], 7);
246        let hap = ref_haplotype();
247        let cpgs = vec![1u32, 5];
248        let mate_positions: Vec<u32> = (0u32..7).collect();
249
250        let mut tally = CpgTruthTally::new();
251        tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, true);
252
253        assert_eq!(tally.get(0, 1), Some((1, 0)), "site 1 → 1 meth");
254        assert_eq!(tally.get(0, 5), Some((0, 1)), "site 5 → 1 unmeth");
255        assert_eq!(tally.len(), 2);
256    }
257
258    #[test]
259    fn test_record_mate_ga_fragment_methylated_bottom() {
260        // GA fragment: queries bottom[site_p + 1].
261        // Bitmap: bottom[2]=true (covers site_p=1), bottom[6]=false (covers site_p=5).
262        let cm = cm_with_bottom(&[2], 7);
263        let hap = ref_haplotype();
264        let cpgs = vec![1u32, 5];
265        let mate_positions: Vec<u32> = (0u32..7).collect();
266
267        let mut tally = CpgTruthTally::new();
268        tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, false);
269
270        assert_eq!(tally.get(0, 1), Some((1, 0)), "site 1 (GA) → 1 meth");
271        assert_eq!(tally.get(0, 5), Some((0, 1)), "site 5 (GA) → 1 unmeth");
272    }
273
274    #[test]
275    fn test_record_mate_only_counts_cpgs_in_span() {
276        // Mate covers ref positions [3, 6] only. CpG at 1 is OUT of range,
277        // CpG at 5 is IN range.
278        let cm = cm_with_top(&[5], 10);
279        let hap = ref_haplotype();
280        let cpgs = vec![1u32, 5];
281        let mate_positions: Vec<u32> = (3u32..7).collect();
282
283        let mut tally = CpgTruthTally::new();
284        tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, true);
285
286        assert_eq!(tally.get(0, 1), None, "out-of-span CpG must not be tallied");
287        assert_eq!(tally.get(0, 5), Some((1, 0)));
288    }
289
290    #[test]
291    fn test_record_mate_ga_excludes_site_when_bottom_c_falls_outside_span() {
292        // GA fragment: site_p=5 has bottom-C at top-pos 6. If mate spans
293        // [3, 5], the bottom-C (6) is NOT covered → must not tally.
294        let cm = cm_with_bottom(&[6], 10);
295        let hap = ref_haplotype();
296        let cpgs = vec![5u32];
297        let mate_positions: Vec<u32> = (3u32..6).collect(); // 3, 4, 5 — no 6.
298
299        let mut tally = CpgTruthTally::new();
300        tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, false);
301
302        assert_eq!(tally.get(0, 5), None);
303    }
304
305    #[test]
306    fn test_record_mate_aggregates_multiple_calls_at_same_site() {
307        let cm = cm_with_top(&[1], 7);
308        let hap = ref_haplotype();
309        let cpgs = vec![1u32];
310        let mate_positions: Vec<u32> = (0u32..7).collect();
311
312        let mut tally = CpgTruthTally::new();
313        tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, true);
314        tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, true);
315        tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, true);
316
317        assert_eq!(tally.get(0, 1), Some((3, 0)), "three meth calls aggregate to 3/0");
318    }
319
320    #[test]
321    fn test_write_bedgraph_format() {
322        // Build a tally with two sites and write to a temp file. Verify the
323        // exact MethylDackel-format output.
324        let mut tally = CpgTruthTally::new();
325        tally.counts.insert((0, 1), (3, 1));
326        tally.counts.insert((0, 5), (0, 4));
327
328        let dict =
329            SequenceDictionary::from_entries(vec![crate::sequence_dict::SequenceMetadata::new(
330                0,
331                "chr1".to_string(),
332                100,
333            )]);
334
335        let tmp =
336            std::env::temp_dir().join(format!("holodeck_cpgtruth_{}.bedGraph", std::process::id()));
337        tally.write_bedgraph(&dict, &tmp).unwrap();
338
339        let written = std::fs::read_to_string(&tmp).unwrap();
340        std::fs::remove_file(&tmp).ok();
341
342        let lines: Vec<&str> = written.lines().collect();
343        assert_eq!(lines.len(), 3, "header + 2 data rows");
344        assert!(lines[0].starts_with("track "), "first line must be a bedGraph track header");
345        // Site at p=1: rate = round(100 * 3/4) = 75.
346        assert_eq!(lines[1], "chr1\t1\t2\t75\t3\t1");
347        // Site at p=5: rate = round(100 * 0/4) = 0.
348        assert_eq!(lines[2], "chr1\t5\t6\t0\t0\t4");
349    }
350}