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}