use rand::Rng;
use bitvec::vec::BitVec;
pub(crate) const DEFAULT_ISLAND_RATE: f64 = 0.1;
pub(crate) const DEFAULT_SHORE_RATE: f64 = 0.5;
pub(crate) const DEFAULT_OPEN_SEA_RATE: f64 = 0.85;
pub(crate) const DEFAULT_CORRELATION_LENGTH_BP: f64 = 1000.0;
pub(crate) const DEFAULT_HEMI_RATE: f64 = 0.01;
const ISLAND_MIN_WINDOW_BP: usize = 200;
const ISLAND_MIN_GC: f64 = 0.5;
const ISLAND_MIN_OE_RATIO: f64 = 0.6;
const SHORE_WIDTH_BP: u32 = 2000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CpgContext {
Island,
Shore,
OpenSea,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ContextParams {
pub(crate) rate: f64,
pub(crate) correlation_length_bp: f64,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct MethylationModel {
pub(crate) island: ContextParams,
pub(crate) shore: ContextParams,
pub(crate) open_sea: ContextParams,
pub(crate) hemi_rate: f64,
}
impl MethylationModel {
pub(crate) fn validate(&self) -> anyhow::Result<()> {
for (name, p) in
[("island", &self.island), ("shore", &self.shore), ("open-sea", &self.open_sea)]
{
if !p.rate.is_finite() || !(0.0..=1.0).contains(&p.rate) {
anyhow::bail!("--methylation-rate-{name} must be in [0.0, 1.0]");
}
if !p.correlation_length_bp.is_finite() || p.correlation_length_bp <= 0.0 {
anyhow::bail!("--methylation-correlation-length-{name} must be a finite value > 0");
}
}
if !self.hemi_rate.is_finite() || !(0.0..=1.0).contains(&self.hemi_rate) {
anyhow::bail!("--hemimethylation-rate must be in [0.0, 1.0]");
}
Ok(())
}
fn params_for(&self, context: CpgContext) -> &ContextParams {
match context {
CpgContext::Island => &self.island,
CpgContext::Shore => &self.shore,
CpgContext::OpenSea => &self.open_sea,
}
}
}
#[derive(Debug, Clone)]
pub struct MethylationTable {
top: BitVec,
bottom: BitVec,
}
impl MethylationTable {
#[cfg(test)]
#[must_use]
pub(crate) fn empty(len: usize) -> Self {
Self::with_len(len)
}
#[must_use]
pub(crate) fn with_len(len: usize) -> Self {
Self { top: BitVec::repeat(false, len), bottom: BitVec::repeat(false, len) }
}
pub(crate) fn from_haplotype(
haplotype: &crate::haplotype::Haplotype,
reference: &[u8],
model: &MethylationModel,
rng: &mut impl Rng,
) -> Self {
assert!(model.validate().is_ok(), "invalid MethylationModel: {model:?}");
#[expect(clippy::cast_possible_truncation, reason = "reference length fits in u32")]
let cap = haplotype.hap_position_for(reference.len() as u32) as usize;
let (hap_bases, _ref_positions, _hap_start) = haplotype.extract_fragment(reference, 0, cap);
let len = hap_bases.len();
let mut table = Self::with_len(len);
if len < 2 {
return table;
}
let cpg_top_c = find_reference_cpgs(&hap_bases);
if cpg_top_c.is_empty() {
return table;
}
let island = island_mask(&hap_bases);
let contexts = classify_cpg_contexts(&cpg_top_c, &island);
let mut prev_state: Option<bool> = None;
let mut prev_pos: u32 = 0;
for (k, &c) in cpg_top_c.iter().enumerate() {
let params = model.params_for(contexts[k]);
let state = match prev_state {
None => rng.random::<f64>() < params.rate,
Some(prev) => {
let d = f64::from(c - prev_pos);
let keep_prob = (-d / params.correlation_length_bp).exp();
if rng.random::<f64>() < keep_prob {
prev
} else {
rng.random::<f64>() < params.rate
}
}
};
if state {
let c = c as usize;
table.top.set(c, true);
table.bottom.set(c + 1, true);
if rng.random::<f64>() < model.hemi_rate {
if rng.random::<bool>() {
table.top.set(c, false);
} else {
table.bottom.set(c + 1, false);
}
}
}
prev_state = Some(state);
prev_pos = c;
}
table
}
#[must_use]
pub fn is_methylated(&self, pos: u32, is_negative_strand: bool) -> bool {
let bv = if is_negative_strand { &self.bottom } else { &self.top };
bv.get(pos as usize).is_some_and(|b| *b)
}
#[cfg(test)]
#[must_use]
pub(crate) fn len(&self) -> usize {
self.top.len()
}
#[cfg(test)]
#[must_use]
pub(crate) fn is_empty(&self) -> bool {
self.top.is_empty()
}
pub(crate) fn set_top(&mut self, pos: usize, value: bool) {
self.top.set(pos, value);
}
pub(crate) fn set_bottom(&mut self, pos: usize, value: bool) {
self.bottom.set(pos, value);
}
}
#[derive(Debug, Clone)]
pub struct ContigMethylation {
per_haplotype: Vec<MethylationTable>,
}
impl ContigMethylation {
pub(crate) fn from_haplotypes(
haplotypes: &[crate::haplotype::Haplotype],
reference: &[u8],
model: &MethylationModel,
rng: &mut impl Rng,
) -> Self {
let per_haplotype = haplotypes
.iter()
.map(|hap| MethylationTable::from_haplotype(hap, reference, model, rng))
.collect();
Self { per_haplotype }
}
#[must_use]
pub(crate) fn from_tables(per_haplotype: Vec<MethylationTable>) -> Self {
Self { per_haplotype }
}
#[must_use]
pub fn table_for(&self, haplotype_index: usize) -> &MethylationTable {
&self.per_haplotype[haplotype_index]
}
#[must_use]
pub fn len(&self) -> usize {
self.per_haplotype.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.per_haplotype.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum MethylationMode {
#[value(alias = "bisulfite")]
EmSeq,
Taps,
}
impl MethylationMode {
#[must_use]
pub fn as_seed_str(self) -> &'static str {
match self {
Self::EmSeq => "em-seq",
Self::Taps => "taps",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct MethylationConfig<'a> {
pub contig_methylation: &'a ContigMethylation,
pub mode: MethylationMode,
pub conversion_rate: f64,
pub failure_rate: f64,
}
#[must_use]
pub(crate) fn find_reference_cpgs(reference: &[u8]) -> Vec<u32> {
let mut out = Vec::new();
if reference.len() < 2 {
return out;
}
for i in 0..reference.len() - 1 {
let c0 = reference[i].to_ascii_uppercase();
let c1 = reference[i + 1].to_ascii_uppercase();
if c0 == b'C' && c1 == b'G' {
#[expect(clippy::cast_possible_truncation, reason = "ref position fits u32")]
out.push(i as u32);
}
}
out
}
#[expect(
clippy::similar_names,
reason = "is_c/is_g and n_c/n_g/n_cg mirror the C/G/CpG quantities they track"
)]
fn island_mask(seq: &[u8]) -> BitVec {
let len = seq.len();
let window = ISLAND_MIN_WINDOW_BP;
let mut mask = BitVec::repeat(false, len);
if len < window {
return mask;
}
let is_c = |j: usize| seq[j].eq_ignore_ascii_case(&b'C');
let is_g = |j: usize| seq[j].eq_ignore_ascii_case(&b'G');
let is_cg = |j: usize| j + 1 < len && is_c(j) && is_g(j + 1);
let mut n_c = (0..window).filter(|&j| is_c(j)).count();
let mut n_g = (0..window).filter(|&j| is_g(j)).count();
let mut n_cg = (0..window - 1).filter(|&j| is_cg(j)).count();
let window_f = window as f64;
let mut painted_end = 0usize;
for a in 0..=(len - window) {
let gc_ok = (n_c + n_g) as f64 > ISLAND_MIN_GC * window_f;
let oe_ok = n_c > 0
&& n_g > 0
&& (n_cg as f64) * window_f > ISLAND_MIN_OE_RATIO * (n_c as f64) * (n_g as f64);
if gc_ok && oe_ok {
let start = painted_end.max(a);
for p in start..(a + window) {
mask.set(p, true);
}
painted_end = a + window;
}
if a < len - window {
let s = a + 1;
n_c = n_c + usize::from(is_c(s + window - 1)) - usize::from(is_c(s - 1));
n_g = n_g + usize::from(is_g(s + window - 1)) - usize::from(is_g(s - 1));
n_cg = n_cg + usize::from(is_cg(s + window - 2)) - usize::from(is_cg(s - 1));
}
}
mask
}
fn island_runs(mask: &BitVec) -> Vec<(u32, u32)> {
let mut runs = Vec::new();
let mut start: Option<usize> = None;
for i in 0..mask.len() {
if mask[i] {
start.get_or_insert(i);
} else if let Some(s) = start.take() {
#[expect(clippy::cast_possible_truncation, reason = "positions fit u32")]
runs.push((s as u32, i as u32));
}
}
if let Some(s) = start {
#[expect(clippy::cast_possible_truncation, reason = "positions fit u32")]
runs.push((s as u32, mask.len() as u32));
}
runs
}
fn near_island(c: u32, runs: &[(u32, u32)]) -> bool {
let idx = runs.partition_point(|&(s, _)| s <= c);
if let Some(&(rs, _)) = runs.get(idx)
&& rs - c <= SHORE_WIDTH_BP
{
return true;
}
if idx > 0 {
let (_, re) = runs[idx - 1];
if c >= re && c - (re - 1) <= SHORE_WIDTH_BP {
return true;
}
}
false
}
fn classify_cpg_contexts(cpg_top_c: &[u32], mask: &BitVec) -> Vec<CpgContext> {
let runs = island_runs(mask);
cpg_top_c
.iter()
.map(|&c| {
if mask.get(c as usize).is_some_and(|b| *b) {
CpgContext::Island
} else if near_island(c, &runs) {
CpgContext::Shore
} else {
CpgContext::OpenSea
}
})
.collect()
}
pub fn apply_methylation_conversion(
bases: &mut [u8],
n_genomic: usize,
is_negative_strand: bool,
hap_start: u32,
haplotype_index: usize,
config: &MethylationConfig<'_>,
rng: &mut impl Rng,
) -> bool {
assert!(
config.conversion_rate.is_finite() && (0.0..=1.0).contains(&config.conversion_rate),
"conversion_rate must be a finite value in [0.0, 1.0]; got {}",
config.conversion_rate,
);
assert!(
config.failure_rate.is_finite() && (0.0..=1.0).contains(&config.failure_rate),
"failure_rate must be a finite value in [0.0, 1.0]; got {}",
config.failure_rate,
);
let conversion_failed = config.failure_rate > 0.0 && rng.random::<f64>() < config.failure_rate;
let rate =
if conversion_failed { 1.0 - config.conversion_rate } else { config.conversion_rate };
let table = config.contig_methylation.table_for(haplotype_index);
for (i, base) in bases.iter_mut().enumerate().take(n_genomic) {
let b = *base;
if b != b'C' {
continue;
}
debug_assert!(
!base.is_ascii_lowercase(),
"uppercase_in_place runs before apply_methylation_conversion"
);
let pos_idx = if is_negative_strand { n_genomic - 1 - i } else { i };
#[expect(clippy::cast_possible_truncation, reason = "pos_idx fits in u32")]
let hap_pos = hap_start + pos_idx as u32;
let is_meth = table.is_methylated(hap_pos, is_negative_strand);
let should_convert = match config.mode {
MethylationMode::EmSeq => !is_meth,
MethylationMode::Taps => is_meth,
};
if should_convert && rng.random::<f64>() < rate {
*base = b'T';
}
}
conversion_failed
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConversionType {
Ct,
Ga,
}
impl ConversionType {
#[must_use]
pub fn as_tag_str(self) -> &'static str {
match self {
Self::Ct => "CT",
Self::Ga => "GA",
}
}
#[must_use]
pub fn from_strand(is_top: bool) -> Self {
if is_top { Self::Ct } else { Self::Ga }
}
}
#[derive(Debug, Clone)]
pub struct MethylationAnnotation {
pub conversion_type: ConversionType,
pub conversion_failed: bool,
pub r1_pre_conversion_bases: Option<Vec<u8>>,
pub r2_pre_conversion_bases: Option<Vec<u8>>,
pub r1_call_tags: Option<crate::methylation_tags::CallTags>,
pub r2_call_tags: Option<crate::methylation_tags::CallTags>,
}
impl MethylationAnnotation {
#[must_use]
pub fn r1_tags(&self) -> Option<MethylationRecordTags<'_>> {
self.r1_pre_conversion_bases.as_deref().map(|bases| MethylationRecordTags {
conversion_type: self.conversion_type,
conversion_failed: self.conversion_failed,
pre_conversion_bases: bases,
call_tags: self.r1_call_tags.as_ref(),
})
}
#[must_use]
pub fn r2_tags(&self) -> Option<MethylationRecordTags<'_>> {
self.r2_pre_conversion_bases.as_deref().map(|bases| MethylationRecordTags {
conversion_type: self.conversion_type,
conversion_failed: self.conversion_failed,
pre_conversion_bases: bases,
call_tags: self.r2_call_tags.as_ref(),
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct MethylationRecordTags<'a> {
pub conversion_type: ConversionType,
pub conversion_failed: bool,
pub pre_conversion_bases: &'a [u8],
pub call_tags: Option<&'a crate::methylation_tags::CallTags>,
}
#[cfg(test)]
mod tests {
use rand::SeedableRng;
use rand::rngs::SmallRng;
use super::*;
use crate::haplotype::build_haplotypes;
use crate::vcf::genotype::{Genotype, VariantRecord};
#[test]
fn test_find_reference_cpgs_basic() {
assert_eq!(find_reference_cpgs(b"ACGTACG"), vec![1, 5]);
}
#[test]
fn test_find_reference_cpgs_case_insensitive() {
assert_eq!(find_reference_cpgs(b"acgTaCg"), vec![1, 5]);
}
#[test]
fn test_find_reference_cpgs_empty_and_short() {
assert!(find_reference_cpgs(b"").is_empty());
assert!(find_reference_cpgs(b"C").is_empty());
assert!(find_reference_cpgs(b"AT").is_empty());
assert_eq!(find_reference_cpgs(b"CG"), vec![0]);
}
fn single_hap_cm(table: MethylationTable) -> ContigMethylation {
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()
}
fn snp_variant(pos: u32, ref_base: u8, alt_base: u8, gt: &str) -> VariantRecord {
VariantRecord {
position: pos,
ref_allele: vec![ref_base],
alt_alleles: vec![vec![alt_base]],
genotype: Genotype::parse(gt).unwrap(),
}
}
fn indel_variant(pos: u32, ref_allele: &[u8], alt_allele: &[u8], gt: &str) -> VariantRecord {
VariantRecord {
position: pos,
ref_allele: ref_allele.to_vec(),
alt_alleles: vec![alt_allele.to_vec()],
genotype: Genotype::parse(gt).unwrap(),
}
}
fn model(rate: f64, l: f64, hemi: f64) -> MethylationModel {
let ctx = ContextParams { rate, correlation_length_bp: l };
MethylationModel { island: ctx, shore: ctx, open_sea: ctx, hemi_rate: hemi }
}
fn uniform_model(rate: f64) -> MethylationModel {
model(rate, DEFAULT_CORRELATION_LENGTH_BP, 0.0)
}
fn acgt_repeat(units: usize) -> Vec<u8> {
b"ACGT".repeat(units)
}
#[test]
fn test_from_haplotype_matches_reference_for_no_variants_haplotype() {
let reference = b"ACGTACGT";
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(42);
let table = MethylationTable::from_haplotype(
hap_borrow(&hap),
reference,
&uniform_model(1.0),
&mut rng,
);
assert_eq!(table.len(), reference.len());
for i in 0u32..8 {
let want_top = i == 1 || i == 5;
let want_bottom = i == 2 || i == 6;
assert_eq!(table.is_methylated(i, false), want_top, "top[{i}] mismatch");
assert_eq!(table.is_methylated(i, true), want_bottom, "bottom[{i}] mismatch");
}
}
fn hap_borrow(h: &crate::haplotype::Haplotype) -> &crate::haplotype::Haplotype {
h
}
#[test]
fn test_from_haplotype_snp_creates_cpg() {
let reference = b"ATG";
let variants = vec![snp_variant(1, b'T', b'C', "0|1")];
let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(7));
let mut rng = SmallRng::seed_from_u64(42);
let var_hap_table =
MethylationTable::from_haplotype(&haps[1], reference, &uniform_model(1.0), &mut rng);
assert_eq!(var_hap_table.len(), 3);
assert!(var_hap_table.is_methylated(1, false), "top-strand C at hap pos 1 must be set");
assert!(var_hap_table.is_methylated(2, true), "bottom-strand C at hap pos 2 must be set");
let mut rng2 = SmallRng::seed_from_u64(42);
let ref_hap_table =
MethylationTable::from_haplotype(&haps[0], reference, &uniform_model(1.0), &mut rng2);
assert!(!ref_hap_table.is_methylated(1, false));
assert!(!ref_hap_table.is_methylated(2, true));
}
#[test]
fn test_from_haplotype_snp_destroys_cpg() {
let reference = b"ACG";
let variants = vec![snp_variant(1, b'C', b'T', "0|1")];
let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(7));
let mut rng = SmallRng::seed_from_u64(42);
let table =
MethylationTable::from_haplotype(&haps[1], reference, &uniform_model(1.0), &mut rng);
assert!(!table.is_methylated(0, false));
assert!(!table.is_methylated(1, false));
assert!(!table.is_methylated(2, false));
assert!(!table.is_methylated(0, true));
assert!(!table.is_methylated(1, true));
assert!(!table.is_methylated(2, true));
}
#[test]
fn test_from_haplotype_deletion_bridges_cpg() {
let reference = b"CTG";
let variants = vec![indel_variant(0, b"CT", b"C", "0|1")];
let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(7));
let mut rng = SmallRng::seed_from_u64(42);
let table =
MethylationTable::from_haplotype(&haps[1], reference, &uniform_model(1.0), &mut rng);
assert_eq!(table.len(), 2);
assert!(table.is_methylated(0, false), "top-strand C at hap pos 0 must be set");
assert!(table.is_methylated(1, true), "bottom-strand C at hap pos 1 must be set");
}
#[test]
fn test_from_haplotype_inserted_cpg() {
let reference = b"AG";
let variants = vec![indel_variant(0, b"A", b"AC", "0|1")];
let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(7));
let mut rng = SmallRng::seed_from_u64(42);
let table =
MethylationTable::from_haplotype(&haps[1], reference, &uniform_model(1.0), &mut rng);
assert_eq!(table.len(), 3, "haplotype should be 3 bases (ACG)");
assert!(table.is_methylated(1, false), "inserted top-strand C must be methylated");
assert!(table.is_methylated(2, true), "bottom-strand C at G must be methylated");
}
#[test]
fn test_from_haplotype_multi_base_insertion_not_truncated() {
let reference = b"AG";
let variants = vec![indel_variant(0, b"A", b"ACGCG", "0|1")];
let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(7));
let mut rng = SmallRng::seed_from_u64(42);
let table =
MethylationTable::from_haplotype(&haps[1], reference, &uniform_model(1.0), &mut rng);
assert_eq!(table.len(), 6, "haplotype must materialize all 6 bases (ACGCGG)");
assert!(table.is_methylated(1, false), "top[1] must be set (first CpG)");
assert!(table.is_methylated(2, true), "bottom[2] must be set (first CpG)");
assert!(
table.is_methylated(3, false),
"top[3] must be set (second CpG, lost when truncated)"
);
assert!(
table.is_methylated(4, true),
"bottom[4] must be set (second CpG, lost when truncated)"
);
}
#[test]
#[should_panic(expected = "invalid MethylationModel")]
fn test_from_haplotype_rejects_nan_rate() {
let reference = b"ACGT";
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(42);
let _ =
MethylationTable::from_haplotype(&hap, reference, &uniform_model(f64::NAN), &mut rng);
}
#[test]
#[should_panic(expected = "invalid MethylationModel")]
fn test_from_haplotype_rejects_rate_above_one() {
let reference = b"ACGT";
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(42);
let _ = MethylationTable::from_haplotype(&hap, reference, &uniform_model(1.5), &mut rng);
}
#[test]
fn test_methylation_model_validate_rejects_bad_fields() {
let mut m = uniform_model(0.5);
m.island.rate = 1.5;
assert!(format!("{}", m.validate().unwrap_err()).contains("--methylation-rate-island"));
let mut m = uniform_model(0.5);
m.shore.correlation_length_bp = 0.0;
assert!(
format!("{}", m.validate().unwrap_err())
.contains("--methylation-correlation-length-shore")
);
let mut m = uniform_model(0.5);
m.hemi_rate = f64::NAN;
assert!(format!("{}", m.validate().unwrap_err()).contains("--hemimethylation-rate"));
}
#[test]
fn test_from_haplotype_zero_rate_no_methylation() {
let reference = b"ACGTACGTACGT";
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(42);
let table =
MethylationTable::from_haplotype(&hap, reference, &uniform_model(0.0), &mut rng);
#[expect(clippy::cast_possible_truncation, reason = "test reference len fits in u32")]
let len = reference.len() as u32;
for i in 0..len {
assert!(!table.is_methylated(i, false));
assert!(!table.is_methylated(i, true));
}
}
#[test]
fn test_from_haplotype_case_insensitive() {
let reference = b"acgt";
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(42);
let table =
MethylationTable::from_haplotype(&hap, reference, &uniform_model(1.0), &mut rng);
assert!(table.is_methylated(1, false), "lowercase 'cg' must register top-strand C");
assert!(table.is_methylated(2, true), "lowercase 'cg' must register bottom-strand C");
assert!(!table.is_methylated(0, false));
assert!(!table.is_methylated(3, true));
}
#[test]
fn test_from_haplotype_symmetric_by_default() {
let reference = acgt_repeat(200);
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(7);
let table =
MethylationTable::from_haplotype(&hap, &reference, &model(0.5, 1000.0, 0.0), &mut rng);
for site in 0u32..200 {
let top = table.is_methylated(site * 4 + 1, false);
let bot = table.is_methylated(site * 4 + 2, true);
assert_eq!(top, bot, "site {site}: strands must agree with hemi_rate 0");
}
}
#[test]
fn test_walk_stationary_mean_matches_rate() {
let reference = acgt_repeat(5000); let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(42);
let table =
MethylationTable::from_haplotype(&hap, &reference, &model(0.3, 1.0, 0.0), &mut rng);
let meth = (0u32..5000).filter(|&s| table.is_methylated(s * 4 + 1, false)).count();
let frac = meth as f64 / 5000.0;
assert!((0.27..=0.33).contains(&frac), "stationary mean {frac} should be ~0.3");
}
#[test]
fn test_walk_autocorrelation_present_with_long_l_absent_with_short_l() {
let reference = acgt_repeat(4000);
let hap = ref_haplotype();
let agreement = |l: f64, seed: u64| {
let mut rng = SmallRng::seed_from_u64(seed);
let table =
MethylationTable::from_haplotype(&hap, &reference, &model(0.5, l, 0.0), &mut rng);
let states: Vec<bool> =
(0u32..4000).map(|s| table.is_methylated(s * 4 + 1, false)).collect();
let agree = states.windows(2).filter(|w| w[0] == w[1]).count();
agree as f64 / (states.len() - 1) as f64
};
assert!(agreement(10_000.0, 1) > 0.9, "long correlation length should give long runs");
let indep = agreement(1.0, 2);
assert!((0.42..=0.58).contains(&indep), "tiny L should decorrelate neighbours: {indep}");
}
#[test]
fn test_walk_hemi_rate_produces_hemimethylation() {
let reference = acgt_repeat(3000);
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(42);
let table =
MethylationTable::from_haplotype(&hap, &reference, &model(1.0, 1.0, 0.3), &mut rng);
let (mut hemi, mut top_only, mut bot_only) = (0usize, 0usize, 0usize);
for s in 0u32..3000 {
let top = table.is_methylated(s * 4 + 1, false);
let bot = table.is_methylated(s * 4 + 2, true);
if top != bot {
hemi += 1;
if top {
top_only += 1;
} else {
bot_only += 1;
}
}
}
let frac = hemi as f64 / 3000.0;
assert!((0.25..=0.35).contains(&frac), "hemi fraction {frac} should be ~0.3");
assert!(top_only > 100 && bot_only > 100, "both hemi directions should occur");
}
#[test]
fn test_walk_island_hypomethylated_relative_to_open_sea() {
let os_unit = b"AATTCGAATT"; let os_units = 2000usize; let island_units = 1500usize; let mut reference = Vec::new();
for _ in 0..os_units {
reference.extend_from_slice(os_unit);
}
let island_start = reference.len();
for _ in 0..island_units {
reference.extend_from_slice(b"CG");
}
let island_end = reference.len();
for _ in 0..os_units {
reference.extend_from_slice(os_unit);
}
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(42);
let m = MethylationModel {
island: ContextParams { rate: 0.1, correlation_length_bp: 10.0 },
shore: ContextParams { rate: 0.5, correlation_length_bp: 10.0 },
open_sea: ContextParams { rate: 0.85, correlation_length_bp: 10.0 },
hemi_rate: 0.0,
};
let table = MethylationTable::from_haplotype(&hap, &reference, &m, &mut rng);
let cpgs = find_reference_cpgs(&reference);
let (mut isl_m, mut isl_n, mut sea_m, mut sea_n) = (0usize, 0usize, 0usize, 0usize);
for &c in &cpgs {
let cu = c as usize;
let methylated = table.is_methylated(c, false);
if cu >= island_start + 200 && cu < island_end - 200 {
isl_n += 1;
isl_m += usize::from(methylated);
} else if cu + 6000 < island_start || cu > island_end + 6000 {
sea_n += 1;
sea_m += usize::from(methylated);
}
}
let isl_frac = isl_m as f64 / isl_n as f64;
let sea_frac = sea_m as f64 / sea_n as f64;
assert!(isl_frac < 0.35, "island interior should be hypomethylated, got {isl_frac}");
assert!(sea_frac > 0.65, "open sea should be hypermethylated, got {sea_frac}");
assert!(isl_frac < sea_frac, "island must be less methylated than open sea");
}
#[test]
fn test_walk_determinism_fixed_seed() {
let reference = acgt_repeat(500);
let hap = ref_haplotype();
let m = model(0.6, 500.0, 0.05);
let run = || {
let mut rng = SmallRng::seed_from_u64(123);
let t = MethylationTable::from_haplotype(&hap, &reference, &m, &mut rng);
(0u32..2000)
.map(|p| (t.is_methylated(p, false), t.is_methylated(p, true)))
.collect::<Vec<_>>()
};
assert_eq!(run(), run(), "same seed must produce identical methylation");
}
#[test]
fn test_walk_no_cpg_and_single_cpg_do_not_panic() {
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(1);
let t = MethylationTable::from_haplotype(&hap, b"AAAATTTT", &uniform_model(1.0), &mut rng);
for i in 0..8 {
assert!(!t.is_methylated(i, false) && !t.is_methylated(i, true));
}
let t = MethylationTable::from_haplotype(&hap, b"ACGT", &uniform_model(1.0), &mut rng);
assert!(t.is_methylated(1, false) && t.is_methylated(2, true));
}
#[test]
fn test_walk_shore_rate_is_intermediate_between_island_and_open_sea() {
let os_unit = b"AATTCGAATT"; let mut seq = b"CG".repeat(150); let island_end = seq.len();
for _ in 0..1500 {
seq.extend_from_slice(os_unit); }
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(42);
let m = MethylationModel {
island: ContextParams { rate: 0.1, correlation_length_bp: 30.0 },
shore: ContextParams { rate: 0.5, correlation_length_bp: 30.0 },
open_sea: ContextParams { rate: 0.85, correlation_length_bp: 30.0 },
hemi_rate: 0.0,
};
let table = MethylationTable::from_haplotype(&hap, &seq, &m, &mut rng);
let cpgs = find_reference_cpgs(&seq);
let mean_over = |lo: usize, hi: usize| {
let v: Vec<bool> = cpgs
.iter()
.filter(|&&c| (c as usize) >= lo && (c as usize) < hi)
.map(|&c| table.is_methylated(c, false))
.collect();
assert!(!v.is_empty(), "no CpGs in [{lo}, {hi})");
v.iter().filter(|&&b| b).count() as f64 / v.len() as f64
};
let island = mean_over(50, island_end - 20);
let shore = mean_over(island_end + 400, island_end + 1900);
let open_sea = mean_over(island_end + 6000, seq.len());
assert!(island < shore, "island {island} should be below shore {shore}");
assert!(shore < open_sea, "shore {shore} should be below open sea {open_sea}");
assert!((0.3..=0.7).contains(&shore), "shore mean {shore} should be ~0.5");
}
#[test]
fn test_from_haplotypes_allele_specific_methylation() {
let reference = acgt_repeat(2000); let haps = build_haplotypes(&[], 2, &mut SmallRng::seed_from_u64(0));
let mut rng = SmallRng::seed_from_u64(42);
let cm =
ContigMethylation::from_haplotypes(&haps, &reference, &model(0.5, 1.0, 0.0), &mut rng);
let (h0, h1) = (cm.table_for(0), cm.table_for(1));
let differ = (0u32..2000)
.filter(|&s| h0.is_methylated(s * 4 + 1, false) != h1.is_methylated(s * 4 + 1, false))
.count();
assert!(
differ > 600,
"haplotypes should diverge at many CpGs; only {differ}/2000 differed"
);
}
#[test]
fn test_island_mask_detects_gc_cpg_dense_block() {
let mut seq = b"AT".repeat(400); let island_start = seq.len();
seq.extend_from_slice(&b"CG".repeat(150)); let island_end = seq.len();
seq.extend_from_slice(&b"AT".repeat(400));
let mask = island_mask(&seq);
assert!(mask[island_start + 150], "CG-dense interior should be island");
assert!(!mask[10], "AT-rich flank should not be island");
assert!(!mask[island_end + 400], "far AT flank should not be island");
}
#[test]
fn test_island_mask_none_in_at_rich_or_short() {
assert!(island_mask(&b"AT".repeat(500)).not_any(), "AT-rich → no island");
assert!(island_mask(b"CGCGCG").not_any(), "sub-window sequence → no island");
}
#[test]
fn test_classify_cpg_contexts_island_shore_open_sea() {
let mut seq = b"CG".repeat(150); let island_end = seq.len();
seq.extend_from_slice(&b"AATTCGAATT".repeat(1000)); let cpgs = find_reference_cpgs(&seq);
let mask = island_mask(&seq);
let contexts = classify_cpg_contexts(&cpgs, &mask);
assert_eq!(contexts[0], CpgContext::Island);
let ctx_at = |target: usize| {
let idx = cpgs.iter().position(|&c| c as usize >= target).unwrap();
contexts[idx]
};
assert_eq!(ctx_at(island_end + 1000), CpgContext::Shore, "within 2 kb → shore");
assert_eq!(ctx_at(island_end + 5000), CpgContext::OpenSea, "beyond 2 kb → open sea");
}
#[test]
fn test_from_haplotype_empty_and_short() {
let hap = ref_haplotype();
let mut rng = SmallRng::seed_from_u64(42);
let table = MethylationTable::from_haplotype(&hap, b"", &uniform_model(1.0), &mut rng);
assert!(table.is_empty());
assert_eq!(table.len(), 0);
let table = MethylationTable::from_haplotype(&hap, b"C", &uniform_model(1.0), &mut rng);
assert_eq!(table.len(), 1);
assert!(!table.is_methylated(0, false));
assert!(!table.is_methylated(0, true));
}
#[test]
fn test_is_methylated_strand_selection() {
let mut table = MethylationTable::empty(10);
table.set_top(3, true);
table.set_bottom(7, true);
assert!(table.is_methylated(3, false));
assert!(!table.is_methylated(3, true));
assert!(!table.is_methylated(7, false));
assert!(table.is_methylated(7, true));
}
#[test]
fn test_is_methylated_out_of_range() {
let table = MethylationTable::empty(10);
assert!(!table.is_methylated(99, false));
assert!(!table.is_methylated(99, true));
assert!(!table.is_methylated(u32::MAX, false));
}
#[test]
fn test_empty_table_returns_false_everywhere() {
let table = MethylationTable::empty(100);
assert_eq!(table.len(), 100);
assert!(!table.is_empty());
for i in 0..100 {
assert!(!table.is_methylated(i, false));
assert!(!table.is_methylated(i, true));
}
}
#[test]
fn test_contig_methylation_per_haplotype_independent() {
let reference = b"ACG"; let variants = vec![snp_variant(1, b'C', b'A', "0|1")];
let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(0));
let mut rng = SmallRng::seed_from_u64(42);
let cm =
ContigMethylation::from_haplotypes(&haps, reference, &uniform_model(1.0), &mut rng);
assert_eq!(cm.len(), 2);
assert!(!cm.is_empty());
let t0 = cm.table_for(0);
assert!(t0.is_methylated(1, false));
assert!(t0.is_methylated(2, true));
let t1 = cm.table_for(1);
assert!(!t1.is_methylated(1, false));
assert!(!t1.is_methylated(2, true));
}
#[test]
fn test_apply_methylation_conversion_em_seq_zero_meth_full_conversion_forward() {
let cm = single_hap_cm(MethylationTable::empty(20));
let mut bases = b"ACGTACGT".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 1.0,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
assert_eq!(&bases, b"ATGTATGT");
}
#[test]
fn test_apply_methylation_conversion_em_seq_full_meth_no_conversion() {
let mut table = MethylationTable::empty(20);
table.set_top(11, true);
table.set_top(15, true);
let cm = single_hap_cm(table);
let mut bases = b"ACGTACGT".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 1.0,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
assert_eq!(&bases, b"ACGTACGT");
}
#[test]
fn test_apply_methylation_conversion_em_seq_negative_strand_uses_reversed_index() {
let mut table = MethylationTable::empty(20);
table.set_bottom(17, true);
let cm = single_hap_cm(table);
let mut bases = b"CAAAC".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 1.0,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 5, true, 13, 0, &c, &mut rng);
assert_eq!(&bases, b"CAAAT");
}
#[test]
fn test_apply_methylation_conversion_skips_adapter_bases() {
let cm = single_hap_cm(MethylationTable::empty(10));
let mut bases = b"ACGCCNNN".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 1.0,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 3, false, 0, 0, &c, &mut rng);
assert_eq!(&bases, b"ATGCCNNN");
}
#[test]
fn test_failed_molecule_retains_all_should_convert_cytosines() {
let cm = single_hap_cm(MethylationTable::empty(20));
let mut bases = b"ACGTACGT".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 1.0,
failure_rate: 1.0,
};
let failed = apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
assert!(failed, "molecule should be flagged as a conversion failure");
assert_eq!(&bases, b"ACGTACGT", "failed molecule must retain every should-convert C");
}
#[test]
fn test_failed_molecule_converts_at_one_minus_conversion_rate() {
let cm = single_hap_cm(MethylationTable::empty(20));
let mut bases = b"ACGTACGT".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 0.0,
failure_rate: 1.0,
};
let failed = apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
assert!(failed);
assert_eq!(&bases, b"ATGTATGT", "failed camp at 1 - 0.0 = 1.0 converts every C");
}
#[test]
fn test_failure_rate_zero_never_flags_failure() {
let cm = single_hap_cm(MethylationTable::empty(20));
let mut bases = b"ACGTACGT".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 1.0,
failure_rate: 0.0,
};
let failed = apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
assert!(!failed, "failure_rate 0.0 must never flag a failure");
assert_eq!(&bases, b"ATGTATGT", "non-failed molecule at rate 1.0 converts every C");
}
#[test]
fn test_zero_genomic_bases_is_noop_but_still_draws_failure() {
let cm = single_hap_cm(MethylationTable::empty(20));
let mut bases = b"CCCCCCCC".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 1.0,
failure_rate: 1.0,
};
let failed = apply_methylation_conversion(&mut bases, 0, true, 10, 0, &c, &mut rng);
assert!(failed, "failure draw is independent of genomic base count");
assert_eq!(&bases, b"CCCCCCCC", "zero genomic bases must leave the buffer untouched");
}
#[test]
fn test_failure_rate_observed_fraction_matches() {
let cm = single_hap_cm(MethylationTable::empty(8));
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 0.999,
failure_rate: 0.5,
};
let n = 5000;
let mut failures = 0;
for _ in 0..n {
let mut bases = b"ACGTACGT".to_vec();
if apply_methylation_conversion(&mut bases, 8, false, 0, 0, &c, &mut rng) {
failures += 1;
}
}
let frac = f64::from(failures) / f64::from(n);
assert!((0.47..=0.53).contains(&frac), "observed failed fraction {frac} out of band");
}
#[test]
fn test_apply_methylation_conversion_em_seq_partial_conversion_rate_empirical() {
let cm = single_hap_cm(MethylationTable::empty(10_000));
let mut bases = vec![b'C'; 10_000];
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 0.5,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 10_000, false, 0, 0, &c, &mut rng);
#[expect(clippy::naive_bytecount, reason = "test, no bytecount dep")]
let t_count = bases.iter().filter(|&&b| b == b'T').count();
let frac = t_count as f64 / 10_000.0;
assert!((0.48..0.52).contains(&frac), "expected ~50% conversion, got {frac:.3}");
}
#[test]
fn test_apply_methylation_conversion_em_seq_zero_conversion_rate_no_change() {
let cm = single_hap_cm(MethylationTable::empty(10));
let mut bases = b"CCCCCCCC".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 0.0,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 8, false, 0, 0, &c, &mut rng);
assert_eq!(&bases, b"CCCCCCCC");
}
#[test]
fn test_apply_methylation_conversion_only_converts_c_not_other_bases() {
let cm = single_hap_cm(MethylationTable::empty(10));
let mut bases = b"AGTNAGTN".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 1.0,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 8, false, 0, 0, &c, &mut rng);
assert_eq!(&bases, b"AGTNAGTN");
}
#[test]
fn test_apply_methylation_conversion_with_hap_start_offset() {
let mut table = MethylationTable::empty(100);
table.set_top(50, true);
let cm = single_hap_cm(table);
let mut bases = b"AAAAACAAAA".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::EmSeq,
conversion_rate: 1.0,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 10, false, 45, 0, &c, &mut rng);
assert_eq!(&bases, b"AAAAACAAAA");
}
#[test]
fn test_apply_methylation_conversion_taps_methylated_converts() {
let mut table = MethylationTable::empty(20);
table.set_top(15, true);
let cm = single_hap_cm(table);
let mut bases = b"ACGTACGT".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::Taps,
conversion_rate: 1.0,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
assert_eq!(&bases, b"ACGTATGT");
}
#[test]
fn test_apply_methylation_conversion_taps_unmethylated_preserved() {
let cm = single_hap_cm(MethylationTable::empty(20));
let mut bases = b"ACGTACGT".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::Taps,
conversion_rate: 1.0,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
assert_eq!(&bases, b"ACGTACGT");
}
#[test]
fn test_apply_methylation_conversion_taps_negative_strand() {
let mut table = MethylationTable::empty(20);
table.set_bottom(17, true);
let cm = single_hap_cm(table);
let mut bases = b"CAAAC".to_vec();
let mut rng = SmallRng::seed_from_u64(42);
let c = MethylationConfig {
contig_methylation: &cm,
mode: MethylationMode::Taps,
conversion_rate: 1.0,
failure_rate: 0.0,
};
apply_methylation_conversion(&mut bases, 5, true, 13, 0, &c, &mut rng);
assert_eq!(&bases, b"TAAAC");
}
}