use std::collections::HashMap;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use anyhow::{Context, Result};
use crate::haplotype::Haplotype;
use crate::meth::ContigMethylation;
use crate::sequence_dict::SequenceDictionary;
#[derive(Debug, Default)]
pub struct CpgTruthTally {
counts: HashMap<(usize, u32), (u32, u32)>,
}
impl CpgTruthTally {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
#[must_use]
pub(crate) fn len(&self) -> usize {
self.counts.len()
}
#[cfg(test)]
#[must_use]
pub(crate) fn get(&self, contig_idx: usize, ref_pos: u32) -> Option<(u32, u32)> {
self.counts.get(&(contig_idx, ref_pos)).copied()
}
#[allow(clippy::too_many_arguments)]
pub fn record_mate(
&mut self,
contig_idx: usize,
mate_ref_positions: &[u32],
ref_cpgs: &[u32],
methylation: &ContigMethylation,
haplotype: &Haplotype,
haplotype_index: usize,
is_forward_fragment: bool,
) {
if mate_ref_positions.is_empty() || ref_cpgs.is_empty() {
return;
}
let mate_min = mate_ref_positions[0];
let mate_max = *mate_ref_positions.last().unwrap();
let table = methylation.table_for(haplotype_index);
let lo = ref_cpgs.partition_point(|&p| p < mate_min);
let hi = ref_cpgs.partition_point(|&p| p <= mate_max);
for &site_p in &ref_cpgs[lo..hi] {
let covered_pos = if is_forward_fragment { site_p } else { site_p + 1 };
if covered_pos < mate_min || covered_pos > mate_max {
continue;
}
if mate_ref_positions.binary_search(&covered_pos).is_err() {
continue;
}
let hap_pos = haplotype.hap_position_for(covered_pos);
let is_meth = table.is_methylated(hap_pos, !is_forward_fragment);
let entry = self.counts.entry((contig_idx, site_p)).or_insert((0, 0));
if is_meth {
entry.0 = entry.0.saturating_add(1);
} else {
entry.1 = entry.1.saturating_add(1);
}
}
}
pub fn write_bedgraph(&self, dict: &SequenceDictionary, path: &Path) -> Result<()> {
let file = File::create(path)
.with_context(|| format!("creating CpG truth bedGraph: {}", path.display()))?;
let mut w = BufWriter::new(file);
writeln!(
w,
"track type=\"bedGraph\" description=\"holodeck CpG truth (per-haplotype methylation calls per simulated read)\""
)?;
let mut sorted: Vec<((usize, u32), (u32, u32))> =
self.counts.iter().map(|(k, v)| (*k, *v)).collect();
sorted.sort_by_key(|&(k, _)| k);
for ((contig_idx, p), (n_meth, n_unmeth)) in sorted {
let total = n_meth + n_unmeth;
if total == 0 {
continue;
}
let rate = ((f64::from(n_meth) / f64::from(total)) * 100.0).round();
#[expect(clippy::cast_possible_truncation, reason = "rate is in [0, 100]")]
#[expect(clippy::cast_sign_loss, reason = "rate is non-negative")]
let rate = rate as u32;
let contig_name = dict
.get_by_index(contig_idx)
.with_context(|| format!("missing contig index {contig_idx} in dictionary"))?
.name();
writeln!(w, "{contig_name}\t{p}\t{}\t{rate}\t{n_meth}\t{n_unmeth}", p + 1)?;
}
w.flush()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use rand::SeedableRng;
use rand::rngs::SmallRng;
use super::*;
use crate::haplotype::build_haplotypes;
use crate::meth::{ContigMethylation, MethylationTable};
fn cm_with_top(top_meth_positions: &[u32], len: usize) -> ContigMethylation {
let mut table = MethylationTable::empty(len);
for &p in top_meth_positions {
table.set_top(p as usize, true);
}
ContigMethylation::from_tables(vec![table])
}
fn cm_with_bottom(bottom_meth_positions: &[u32], len: usize) -> ContigMethylation {
let mut table = MethylationTable::empty(len);
for &p in bottom_meth_positions {
table.set_bottom(p as usize, true);
}
ContigMethylation::from_tables(vec![table])
}
fn ref_haplotype() -> crate::haplotype::Haplotype {
let haps = build_haplotypes(&[], 1, &mut SmallRng::seed_from_u64(0));
haps.into_iter().next().unwrap()
}
#[test]
fn test_record_mate_ct_fragment_methylated_top() {
let cm = cm_with_top(&[1], 7);
let hap = ref_haplotype();
let cpgs = vec![1u32, 5];
let mate_positions: Vec<u32> = (0u32..7).collect();
let mut tally = CpgTruthTally::new();
tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, true);
assert_eq!(tally.get(0, 1), Some((1, 0)), "site 1 → 1 meth");
assert_eq!(tally.get(0, 5), Some((0, 1)), "site 5 → 1 unmeth");
assert_eq!(tally.len(), 2);
}
#[test]
fn test_record_mate_ga_fragment_methylated_bottom() {
let cm = cm_with_bottom(&[2], 7);
let hap = ref_haplotype();
let cpgs = vec![1u32, 5];
let mate_positions: Vec<u32> = (0u32..7).collect();
let mut tally = CpgTruthTally::new();
tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, false);
assert_eq!(tally.get(0, 1), Some((1, 0)), "site 1 (GA) → 1 meth");
assert_eq!(tally.get(0, 5), Some((0, 1)), "site 5 (GA) → 1 unmeth");
}
#[test]
fn test_record_mate_only_counts_cpgs_in_span() {
let cm = cm_with_top(&[5], 10);
let hap = ref_haplotype();
let cpgs = vec![1u32, 5];
let mate_positions: Vec<u32> = (3u32..7).collect();
let mut tally = CpgTruthTally::new();
tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, true);
assert_eq!(tally.get(0, 1), None, "out-of-span CpG must not be tallied");
assert_eq!(tally.get(0, 5), Some((1, 0)));
}
#[test]
fn test_record_mate_ga_excludes_site_when_bottom_c_falls_outside_span() {
let cm = cm_with_bottom(&[6], 10);
let hap = ref_haplotype();
let cpgs = vec![5u32];
let mate_positions: Vec<u32> = (3u32..6).collect();
let mut tally = CpgTruthTally::new();
tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, false);
assert_eq!(tally.get(0, 5), None);
}
#[test]
fn test_record_mate_aggregates_multiple_calls_at_same_site() {
let cm = cm_with_top(&[1], 7);
let hap = ref_haplotype();
let cpgs = vec![1u32];
let mate_positions: Vec<u32> = (0u32..7).collect();
let mut tally = CpgTruthTally::new();
tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, true);
tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, true);
tally.record_mate(0, &mate_positions, &cpgs, &cm, &hap, 0, true);
assert_eq!(tally.get(0, 1), Some((3, 0)), "three meth calls aggregate to 3/0");
}
#[test]
fn test_write_bedgraph_format() {
let mut tally = CpgTruthTally::new();
tally.counts.insert((0, 1), (3, 1));
tally.counts.insert((0, 5), (0, 4));
let dict =
SequenceDictionary::from_entries(vec![crate::sequence_dict::SequenceMetadata::new(
0,
"chr1".to_string(),
100,
)]);
let tmp =
std::env::temp_dir().join(format!("holodeck_cpgtruth_{}.bedGraph", std::process::id()));
tally.write_bedgraph(&dict, &tmp).unwrap();
let written = std::fs::read_to_string(&tmp).unwrap();
std::fs::remove_file(&tmp).ok();
let lines: Vec<&str> = written.lines().collect();
assert_eq!(lines.len(), 3, "header + 2 data rows");
assert!(lines[0].starts_with("track "), "first line must be a bedGraph track header");
assert_eq!(lines[1], "chr1\t1\t2\t75\t3\t1");
assert_eq!(lines[2], "chr1\t5\t6\t0\t0\t4");
}
}