holodeck_lib/meth.rs
1//! Methylation bitmaps and chemistry-conversion logic.
2//!
3//! Provides per-haplotype methylation state via [`MethylationTable`] and
4//! [`ContigMethylation`], plus the [`apply_methylation_conversion`] free
5//! function that simulates the per-base chemistry of either an em-seq /
6//! bisulfite library (unmethylated C → T) or a TAPS library (methylated C → T).
7//!
8//! # Per-haplotype CpG detection and per-strand bitmaps
9//!
10//! Each haplotype gets its own pair of [`BitVec`]s indexed by haplotype
11//! position (0..haplotype_length):
12//!
13//! - `top[h]` = "the top-strand C at haplotype position `h` is methylated."
14//! - `bottom[h + 1]` = "the bottom-strand C at haplotype position `h + 1` is
15//! methylated."
16//!
17//! Indexing by haplotype position rather than reference position naturally
18//! handles SNPs, insertions, and deletions that create or destroy CpG sites
19//! on a particular haplotype: each haplotype's bitmap reflects the CpG
20//! context that actually exists on that haplotype. Both bitmaps have length
21//! equal to the haplotype's materialized length; positions that don't host a
22//! strand-specific C (or that host a non-CpG cytosine) always read `false`.
23//! Non-CpG cytosines are always treated as unmethylated.
24//!
25//! # Methylation model (the `methylate` generator)
26//!
27//! [`MethylationTable::from_haplotype`] fills these bitmaps with a
28//! context-aware, spatially-correlated model rather than independent per-CpG
29//! coin flips. Each CpG is classified ([`CpgContext`]) into island / shore /
30//! open-sea from the haplotype sequence; a two-state (methylated/unmethylated)
31//! Markov chain then walks the CpG list using that context's [`ContextParams`]
32//! (target rate + correlation length, bundled per-context in
33//! [`MethylationModel`]). The chain's stationary mean equals the context's
34//! target rate while neighbouring CpGs are spatially correlated, so islands
35//! come out hypomethylated, open-sea hypermethylated, with shore gradients in
36//! between. Methylation is **symmetric** across strands by default; sporadic
37//! hemimethylation is introduced per-CpG via [`MethylationModel::hemi_rate`].
38//! Allele-specific methylation falls out naturally because each haplotype is
39//! walked as an independent chain. The model is built once (from CLI flags)
40//! and threaded through [`ContigMethylation::from_haplotypes`] →
41//! [`MethylationTable::from_haplotype`].
42//!
43//! # Chemistry modes
44//!
45//! [`MethylationMode`] selects which class of cytosines is converted to
46//! thymine during chemistry simulation:
47//!
48//! - [`MethylationMode::EmSeq`] -- unmethylated cytosines convert to thymine;
49//! methylated cytosines are preserved. Matches both classical bisulfite
50//! chemistry and enzymatic methyl-seq (em-seq, NEBNext) -- the conversion
51//! patterns are identical.
52//! - [`MethylationMode::Taps`] -- methylated cytosines convert to thymine
53//! (TET oxidation + pyridine borane); unmethylated cytosines are preserved.
54//! The inverse of em-seq: a `C→T` event at a CpG signals methylation.
55
56use rand::Rng;
57
58use bitvec::vec::BitVec;
59
60/// Default target methylation fraction for CpG-island-interior CpGs.
61/// Islands are characteristically hypomethylated. See [`MethylationModel`].
62pub(crate) const DEFAULT_ISLAND_RATE: f64 = 0.1;
63
64/// Default target methylation fraction for CpG-island-shore CpGs
65/// (intermediate). See [`MethylationModel`].
66pub(crate) const DEFAULT_SHORE_RATE: f64 = 0.5;
67
68/// Default target methylation fraction for open-sea CpGs (hypermethylated;
69/// the bulk genomic default). See [`MethylationModel`].
70pub(crate) const DEFAULT_OPEN_SEA_RATE: f64 = 0.85;
71
72/// Default spatial correlation length (bp) for every context. Sets how far
73/// methylation state persists between consecutive CpGs. See
74/// [`ContextParams::correlation_length_bp`].
75pub(crate) const DEFAULT_CORRELATION_LENGTH_BP: f64 = 1000.0;
76
77/// Default sporadic hemimethylation probability. See
78/// [`MethylationModel::hemi_rate`].
79pub(crate) const DEFAULT_HEMI_RATE: f64 = 0.01;
80
81// CpG-island detector thresholds (Gardiner-Garden & Frommer, 1987). Internal
82// constants, not CLI flags: these are the canonical island-calling criteria,
83// not something a simulation user normally retunes.
84
85/// Minimum window length (bp) over which the island criteria are evaluated.
86const ISLAND_MIN_WINDOW_BP: usize = 200;
87
88/// Minimum GC fraction for a window to qualify as a CpG island.
89const ISLAND_MIN_GC: f64 = 0.5;
90
91/// Minimum observed/expected CpG ratio for a window to qualify as an island.
92const ISLAND_MIN_OE_RATIO: f64 = 0.6;
93
94/// Distance (bp) from an island within which a CpG is classified as "shore".
95const SHORE_WIDTH_BP: u32 = 2000;
96
97/// Genomic-context class of a CpG, which selects its methylation parameters.
98///
99/// `Island` / `Shore` / `OpenSea` are the standard CpG-island taxonomy
100/// (Gardiner-Garden 1987; shores from Irizarry 2009). The canonical scheme
101/// also has a "shelf" tier 2-4 kb out; this model folds shelf into open-sea.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub(crate) enum CpgContext {
104 /// Inside a detected CpG island — hypomethylated.
105 Island,
106 /// Within [`SHORE_WIDTH_BP`] of an island — intermediate.
107 Shore,
108 /// Everywhere else — hypermethylated.
109 OpenSea,
110}
111
112/// Per-context methylation parameters: the stationary target rate and the
113/// spatial correlation length.
114#[derive(Debug, Clone, Copy)]
115pub(crate) struct ContextParams {
116 /// Stationary target methylation fraction in `[0.0, 1.0]`. Over any large
117 /// region of this context the mean methylation converges to this value.
118 pub(crate) rate: f64,
119 /// Spatial correlation length L (bp). For two consecutive CpGs separated
120 /// by `d` bp the second keeps the first's methylation state with
121 /// probability `exp(-d / L)`, otherwise it is redrawn from
122 /// `Bernoulli(rate)`. Larger L → longer runs of like-methylated CpGs.
123 /// Must be finite and `> 0`.
124 pub(crate) correlation_length_bp: f64,
125}
126
127/// Resolved per-context methylation model used by the `methylate` generator.
128///
129/// Built once from CLI flags and threaded read-only through
130/// [`ContigMethylation::from_haplotypes`] →
131/// [`MethylationTable::from_haplotype`]. Each CpG is classified into a
132/// [`CpgContext`] from the haplotype sequence, then a two-state
133/// (methylated/unmethylated) Markov chain walks the CpG list using that
134/// context's [`ContextParams`]: the chain's stationary mean equals the
135/// context rate, and spatial autocorrelation decays with genomic distance per
136/// the correlation length. Methylation is symmetric (both strands) by
137/// default; [`Self::hemi_rate`] introduces sporadic per-CpG hemimethylation.
138#[derive(Debug, Clone, Copy)]
139pub(crate) struct MethylationModel {
140 /// Parameters for CpG-island-interior CpGs.
141 pub(crate) island: ContextParams,
142 /// Parameters for island-shore CpGs.
143 pub(crate) shore: ContextParams,
144 /// Parameters for open-sea CpGs.
145 pub(crate) open_sea: ContextParams,
146 /// Probability that a methylated CpG is made hemimethylated — exactly one
147 /// randomly chosen strand is left unmethylated. In `[0.0, 1.0]`.
148 pub(crate) hemi_rate: f64,
149}
150
151impl MethylationModel {
152 /// Validate every context rate and the hemi rate are finite in
153 /// `[0.0, 1.0]` and every correlation length is finite and `> 0`.
154 ///
155 /// Called at the CLI boundary; [`MethylationTable::from_haplotype`] also
156 /// asserts a valid model as defense-in-depth on the `pub(crate)` boundary.
157 ///
158 /// # Errors
159 ///
160 /// Returns an error naming the offending flag if any bound is violated.
161 pub(crate) fn validate(&self) -> anyhow::Result<()> {
162 for (name, p) in
163 [("island", &self.island), ("shore", &self.shore), ("open-sea", &self.open_sea)]
164 {
165 if !p.rate.is_finite() || !(0.0..=1.0).contains(&p.rate) {
166 anyhow::bail!("--methylation-rate-{name} must be in [0.0, 1.0]");
167 }
168 if !p.correlation_length_bp.is_finite() || p.correlation_length_bp <= 0.0 {
169 anyhow::bail!("--methylation-correlation-length-{name} must be a finite value > 0");
170 }
171 }
172 if !self.hemi_rate.is_finite() || !(0.0..=1.0).contains(&self.hemi_rate) {
173 anyhow::bail!("--hemimethylation-rate must be in [0.0, 1.0]");
174 }
175 Ok(())
176 }
177
178 /// The [`ContextParams`] for a given CpG context.
179 fn params_for(&self, context: CpgContext) -> &ContextParams {
180 match context {
181 CpgContext::Island => &self.island,
182 CpgContext::Shore => &self.shore,
183 CpgContext::OpenSea => &self.open_sea,
184 }
185 }
186}
187
188/// Per-haplotype methylation state, one bitmap per strand. Bitmaps are
189/// indexed by **haplotype position** (which may differ from reference
190/// position when the haplotype contains indels).
191///
192/// The strand state is stored in [`bitvec::vec::BitVec`] rather than
193/// `Vec<bool>` because the bitmaps are sized to the full materialized
194/// haplotype length — one bit per base, per strand, per haplotype. At
195/// whole-chromosome scale (e.g. ~250 Mb × 2 strands × ploidy) `Vec<bool>`
196/// would cost 8× the memory for the same information; `bitvec` packs it to
197/// one bit each. That density justifies the extra direct dependency.
198#[derive(Debug, Clone)]
199pub struct MethylationTable {
200 /// Top-strand methylation bitmap.
201 /// `top[h]` is `true` iff the top-strand C at haplotype position `h` is
202 /// methylated. Length equals the haplotype's materialized length;
203 /// positions without a strand-specific C in CpG context hold `false`.
204 top: BitVec,
205 /// Bottom-strand methylation bitmap, same shape as `top` but for the
206 /// reverse-complement strand.
207 bottom: BitVec,
208}
209
210impl MethylationTable {
211 /// Build an empty methylation table of the given length (no methylation
212 /// anywhere). Test-only: production code always builds tables via
213 /// [`Self::from_haplotype`] / [`ContigMethylation::from_haplotypes`].
214 #[cfg(test)]
215 #[must_use]
216 pub(crate) fn empty(len: usize) -> Self {
217 Self::with_len(len)
218 }
219
220 /// Build an empty methylation table with the given length (all bits
221 /// `false`). Used by [`Self::from_haplotype`] and the VCF reader.
222 #[must_use]
223 pub(crate) fn with_len(len: usize) -> Self {
224 Self { top: BitVec::repeat(false, len), bottom: BitVec::repeat(false, len) }
225 }
226
227 /// Build a methylation table for a single haplotype by materializing the
228 /// haplotype's full sequence (via [`crate::haplotype::Haplotype::extract_fragment`]
229 /// over the entire contig length) and assigning methylation with a
230 /// context-aware Markov chain. The resulting bitmap length equals the
231 /// materialized haplotype length, which may differ from the reference
232 /// length when the haplotype carries indels.
233 ///
234 /// Each CpG is classified ([`classify_cpg_contexts`]) into island / shore
235 /// / open-sea, then a two-state (methylated/unmethylated) chain walks the
236 /// CpG list in order. For each CpG the [`ContextParams`] of its context
237 /// give the stationary target `m` and correlation length `L`: the first
238 /// CpG is drawn `Bernoulli(m)`; thereafter, with probability `exp(-d/L)`
239 /// (where `d` is the bp distance to the previous CpG) the previous state
240 /// is kept, otherwise it is redrawn `Bernoulli(m)`. This yields a
241 /// stationary mean of `m` and autocorrelation that decays with genomic
242 /// distance. Across a context boundary the marginal relaxes toward the
243 /// new target over ~`L / spacing` CpGs — the realistic shore gradient —
244 /// so per-context means hold in region interiors, not in transition bands.
245 ///
246 /// Methylation is **symmetric** by default (a methylated CpG sets both the
247 /// top-strand C and the bottom-strand C); with probability
248 /// [`MethylationModel::hemi_rate`] a methylated CpG is made hemimethylated
249 /// by unsetting exactly one randomly chosen strand. The stationary mean `m`
250 /// is the per-CpG *methylated-state* rate; when `hemi_rate > 0` the realized
251 /// per-strand methylated fraction is `m * (1 - hemi_rate / 2)`.
252 ///
253 /// # Panics
254 ///
255 /// Panics if `model` fails [`MethylationModel::validate`] (NaN/out-of-range
256 /// rate or non-positive correlation length). The `methylate` CLI validates
257 /// the model at startup, but this is a `pub(crate)` constructor reachable
258 /// from elsewhere in the crate, so the invariant is asserted here as
259 /// defense-in-depth.
260 pub(crate) fn from_haplotype(
261 haplotype: &crate::haplotype::Haplotype,
262 reference: &[u8],
263 model: &MethylationModel,
264 rng: &mut impl Rng,
265 ) -> Self {
266 assert!(model.validate().is_ok(), "invalid MethylationModel: {model:?}");
267 // Materialize the entire haplotype as one large fragment. The cap
268 // passed to `extract_fragment` is BOTH a pre-allocation hint AND a
269 // truncation limit on the output base count, so it must be large
270 // enough to hold the full materialized haplotype — including all
271 // net-positive insertions. Using a tighter cap (e.g.
272 // `reference.len() + 1`) silently drops haplotype suffix bases when
273 // the haplotype contains insertions adding more than one base, which
274 // can lose CpG sites entirely.
275 //
276 // `hap_position_for(reference.len())` returns the haplotype-coordinate
277 // length of the materialized haplotype (sum of `(alt_len - ref_len)`
278 // across all variants whose `var_end <= reference.len()`, plus
279 // `reference.len()` itself), giving the exact required capacity for
280 // sane VCFs whose variants do not extend past the chromosome end.
281 #[expect(clippy::cast_possible_truncation, reason = "reference length fits in u32")]
282 let cap = haplotype.hap_position_for(reference.len() as u32) as usize;
283 let (hap_bases, _ref_positions, _hap_start) = haplotype.extract_fragment(reference, 0, cap);
284 let len = hap_bases.len();
285 let mut table = Self::with_len(len);
286 if len < 2 {
287 return table;
288 }
289
290 // CpG list (top-strand C positions) on this haplotype's sequence, then
291 // per-CpG context from the same materialized bases so variant-created
292 // / -destroyed CpGs and indel shifts are handled in haplotype coords.
293 let cpg_top_c = find_reference_cpgs(&hap_bases);
294 if cpg_top_c.is_empty() {
295 return table;
296 }
297 let island = island_mask(&hap_bases);
298 let contexts = classify_cpg_contexts(&cpg_top_c, &island);
299
300 let mut prev_state: Option<bool> = None;
301 let mut prev_pos: u32 = 0;
302 for (k, &c) in cpg_top_c.iter().enumerate() {
303 let params = model.params_for(contexts[k]);
304 let state = match prev_state {
305 None => rng.random::<f64>() < params.rate,
306 Some(prev) => {
307 let d = f64::from(c - prev_pos);
308 let keep_prob = (-d / params.correlation_length_bp).exp();
309 if rng.random::<f64>() < keep_prob {
310 prev
311 } else {
312 rng.random::<f64>() < params.rate
313 }
314 }
315 };
316 if state {
317 let c = c as usize;
318 table.top.set(c, true);
319 table.bottom.set(c + 1, true);
320 // Sporadic hemimethylation: drop exactly one strand.
321 if rng.random::<f64>() < model.hemi_rate {
322 if rng.random::<bool>() {
323 table.top.set(c, false);
324 } else {
325 table.bottom.set(c + 1, false);
326 }
327 }
328 }
329 prev_state = Some(state);
330 prev_pos = c;
331 }
332 table
333 }
334
335 /// Return whether the C at haplotype position `pos` is methylated on
336 /// the strand the read came from. For a forward-strand read, queries
337 /// the top-strand bitmap; for a negative-strand read, queries the
338 /// bottom-strand bitmap. Returns `false` for out-of-range positions.
339 #[must_use]
340 pub fn is_methylated(&self, pos: u32, is_negative_strand: bool) -> bool {
341 let bv = if is_negative_strand { &self.bottom } else { &self.top };
342 bv.get(pos as usize).is_some_and(|b| *b)
343 }
344
345 /// Length of each per-strand bitmap (equals the haplotype's materialized
346 /// length, or the length passed to [`Self::empty`]). Test-only.
347 #[cfg(test)]
348 #[must_use]
349 pub(crate) fn len(&self) -> usize {
350 self.top.len()
351 }
352
353 /// Whether both bitmaps are zero length. Test-only; paired with
354 /// [`Self::len`] to satisfy clippy's `len_without_is_empty`.
355 #[cfg(test)]
356 #[must_use]
357 pub(crate) fn is_empty(&self) -> bool {
358 self.top.is_empty()
359 }
360
361 /// Set a top-strand methylation bit at position `pos`. Panics on
362 /// out-of-range index. Used by the VCF reader and tests.
363 pub(crate) fn set_top(&mut self, pos: usize, value: bool) {
364 self.top.set(pos, value);
365 }
366
367 /// Set a bottom-strand methylation bit at position `pos`. Panics on
368 /// out-of-range index. Used by the VCF reader and tests.
369 pub(crate) fn set_bottom(&mut self, pos: usize, value: bool) {
370 self.bottom.set(pos, value);
371 }
372}
373
374/// Per-contig methylation state covering ALL haplotypes for one contig.
375/// Indexed by [`crate::fragment::Fragment::haplotype_index`].
376#[derive(Debug, Clone)]
377pub struct ContigMethylation {
378 /// One [`MethylationTable`] per haplotype, in haplotype-index order.
379 per_haplotype: Vec<MethylationTable>,
380}
381
382impl ContigMethylation {
383 /// Build per-haplotype methylation tables with the context-aware Markov
384 /// model (see [`MethylationTable::from_haplotype`]). Each haplotype is
385 /// walked independently with the shared `rng`, so allele-specific
386 /// methylation arises naturally and the per-contig draw order is
387 /// deterministic for a fixed seed.
388 pub(crate) fn from_haplotypes(
389 haplotypes: &[crate::haplotype::Haplotype],
390 reference: &[u8],
391 model: &MethylationModel,
392 rng: &mut impl Rng,
393 ) -> Self {
394 let per_haplotype = haplotypes
395 .iter()
396 .map(|hap| MethylationTable::from_haplotype(hap, reference, model, rng))
397 .collect();
398 Self { per_haplotype }
399 }
400
401 /// Construct from a pre-built per-haplotype table list. Used by the VCF
402 /// reader and tests; production code uses [`Self::from_haplotypes`].
403 #[must_use]
404 pub(crate) fn from_tables(per_haplotype: Vec<MethylationTable>) -> Self {
405 Self { per_haplotype }
406 }
407
408 /// Return the methylation table for the haplotype at the given index.
409 /// Panics if the index is out of range -- `Fragment::haplotype_index`
410 /// is always derived from a haplotype actually built for the contig,
411 /// so an out-of-range index is a programming error.
412 #[must_use]
413 pub fn table_for(&self, haplotype_index: usize) -> &MethylationTable {
414 &self.per_haplotype[haplotype_index]
415 }
416
417 /// Number of haplotypes covered by this `ContigMethylation`.
418 #[must_use]
419 pub fn len(&self) -> usize {
420 self.per_haplotype.len()
421 }
422
423 /// Whether there are no haplotypes covered.
424 #[must_use]
425 pub fn is_empty(&self) -> bool {
426 self.per_haplotype.is_empty()
427 }
428}
429
430/// Methylation chemistry. Selects which class of cytosines is converted
431/// to thymine during chemistry simulation.
432#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
433pub enum MethylationMode {
434 /// Unmethylated cytosines convert to thymine; methylated cytosines
435 /// are preserved. Matches classical bisulfite chemistry and enzymatic
436 /// methyl-seq. Pass `--methylation-mode bisulfite` as an alias.
437 #[value(alias = "bisulfite")]
438 EmSeq,
439 /// Methylated cytosines convert to thymine (TET oxidation + pyridine
440 /// borane); unmethylated cytosines are preserved. The inverse of
441 /// bisulfite/em-seq: a `C→T` event at a CpG signals methylation.
442 Taps,
443}
444
445impl MethylationMode {
446 /// Canonical seed-string form of this mode. Used in
447 /// [`crate::commands::simulate::Simulate::compute_seed`] so changes to the
448 /// CLI alias system don't accidentally shift seed determinism.
449 #[must_use]
450 pub fn as_seed_str(self) -> &'static str {
451 match self {
452 Self::EmSeq => "em-seq",
453 Self::Taps => "taps",
454 }
455 }
456}
457
458/// Configuration bundle passed through the read-generation pipeline when
459/// methylation chemistry simulation is enabled. Carries the per-contig,
460/// per-haplotype methylation state plus the chemistry parameters.
461#[derive(Debug, Clone, Copy)]
462pub struct MethylationConfig<'a> {
463 /// Per-contig methylation tables, one per haplotype.
464 pub contig_methylation: &'a ContigMethylation,
465 /// Which chemistry to apply (em-seq vs TAPS).
466 pub mode: MethylationMode,
467 /// Probability that a qualifying C is converted to T in a molecule that
468 /// converted normally. Clamped to `[0.0, 1.0]` by the caller; not
469 /// re-validated in the hot loop.
470 pub conversion_rate: f64,
471 /// Per-molecule probability that a fragment is a *conversion failure*.
472 /// Real bisulfite/EM-seq conversion is effectively bimodal: most
473 /// molecules convert near-completely, a small fraction escape conversion
474 /// as a unit (fragments that fail to denature, or re-anneal too fast).
475 /// A failed molecule converts its should-convert cytosines at
476 /// `1.0 - conversion_rate`, so it coherently retains almost all of them
477 /// as C. Drawn once per fragment so both mates agree. Clamped to
478 /// `[0.0, 1.0]` by the caller.
479 ///
480 /// The "near-zero failed rate" intuition assumes `conversion_rate` is
481 /// close to `1.0` (the realistic regime). At the degenerate setting
482 /// `conversion_rate == 0.0` the relationship inverts — failed molecules
483 /// convert at `1.0` (fully) while normal molecules don't convert at all —
484 /// which is a deliberate consequence of pinning the failed rate to
485 /// `1.0 - conversion_rate`, not a special case.
486 pub failure_rate: f64,
487}
488
489/// Reference CpG positions: the 0-based position of the top-strand `C` in
490/// each `CG` dinucleotide (case-insensitive), returned in ascending order.
491///
492/// Shared by the CpG-truth tally ([`crate::output::cpg_truth`]) and the
493/// MT/MB classifier ([`crate::vcf::methylation`]); both need the identical
494/// scan over an unmodified reference.
495#[must_use]
496pub(crate) fn find_reference_cpgs(reference: &[u8]) -> Vec<u32> {
497 let mut out = Vec::new();
498 if reference.len() < 2 {
499 return out;
500 }
501 for i in 0..reference.len() - 1 {
502 let c0 = reference[i].to_ascii_uppercase();
503 let c1 = reference[i + 1].to_ascii_uppercase();
504 if c0 == b'C' && c1 == b'G' {
505 #[expect(clippy::cast_possible_truncation, reason = "ref position fits u32")]
506 out.push(i as u32);
507 }
508 }
509 out
510}
511
512/// Per-base CpG-island mask: `mask[p]` is `true` iff position `p` lies in a
513/// window that satisfies the Gardiner-Garden island criteria
514/// ([`ISLAND_MIN_WINDOW_BP`]-bp window with GC fraction > [`ISLAND_MIN_GC`]
515/// and observed/expected CpG ratio > [`ISLAND_MIN_OE_RATIO`]).
516///
517/// `O(len)`: a single rolling window maintains C, G, and CpG counts, and
518/// qualifying windows are painted into the mask with a monotone cursor so each
519/// base is written at most once. Case-insensitive; non-`ACGT` bases count as
520/// neither GC nor CpG. Sequences shorter than the window yield an all-`false`
521/// mask (no island can be called).
522#[expect(
523 clippy::similar_names,
524 reason = "is_c/is_g and n_c/n_g/n_cg mirror the C/G/CpG quantities they track"
525)]
526fn island_mask(seq: &[u8]) -> BitVec {
527 let len = seq.len();
528 let window = ISLAND_MIN_WINDOW_BP;
529 let mut mask = BitVec::repeat(false, len);
530 if len < window {
531 return mask;
532 }
533
534 let is_c = |j: usize| seq[j].eq_ignore_ascii_case(&b'C');
535 let is_g = |j: usize| seq[j].eq_ignore_ascii_case(&b'G');
536 // A CpG occupies `j` and `j + 1`; both must exist.
537 let is_cg = |j: usize| j + 1 < len && is_c(j) && is_g(j + 1);
538
539 // Counts for the window starting at `a == 0`, covering [0, window).
540 let mut n_c = (0..window).filter(|&j| is_c(j)).count();
541 let mut n_g = (0..window).filter(|&j| is_g(j)).count();
542 // CpG positions fully inside [a, a + window) are `j` in [a, a + window - 1).
543 let mut n_cg = (0..window - 1).filter(|&j| is_cg(j)).count();
544
545 let window_f = window as f64;
546 let mut painted_end = 0usize;
547 for a in 0..=(len - window) {
548 let gc_ok = (n_c + n_g) as f64 > ISLAND_MIN_GC * window_f;
549 // observed/expected = n_cg / (n_c * n_g / window) > threshold, written
550 // without division so a zero C or G count simply fails the test.
551 let oe_ok = n_c > 0
552 && n_g > 0
553 && (n_cg as f64) * window_f > ISLAND_MIN_OE_RATIO * (n_c as f64) * (n_g as f64);
554 if gc_ok && oe_ok {
555 let start = painted_end.max(a);
556 for p in start..(a + window) {
557 mask.set(p, true);
558 }
559 painted_end = a + window;
560 }
561 // Slide to the window starting at `a + 1`, covering [a+1, a+window+1).
562 if a < len - window {
563 let s = a + 1;
564 n_c = n_c + usize::from(is_c(s + window - 1)) - usize::from(is_c(s - 1));
565 n_g = n_g + usize::from(is_g(s + window - 1)) - usize::from(is_g(s - 1));
566 // CpG range shifts from [a, a+window-1) to [s, s+window-1): drop
567 // the pair leaving at `s-1`, add the pair entering at `s+window-2`.
568 n_cg = n_cg + usize::from(is_cg(s + window - 2)) - usize::from(is_cg(s - 1));
569 }
570 }
571 mask
572}
573
574/// Contiguous `[start, end)` runs of `true` bits in an island mask, ascending.
575fn island_runs(mask: &BitVec) -> Vec<(u32, u32)> {
576 let mut runs = Vec::new();
577 let mut start: Option<usize> = None;
578 for i in 0..mask.len() {
579 if mask[i] {
580 start.get_or_insert(i);
581 } else if let Some(s) = start.take() {
582 #[expect(clippy::cast_possible_truncation, reason = "positions fit u32")]
583 runs.push((s as u32, i as u32));
584 }
585 }
586 if let Some(s) = start {
587 #[expect(clippy::cast_possible_truncation, reason = "positions fit u32")]
588 runs.push((s as u32, mask.len() as u32));
589 }
590 runs
591}
592
593/// Whether `c` is within [`SHORE_WIDTH_BP`] (inclusive) of any island run, but
594/// not inside one — callers test the mask for `Island` first. `runs` are
595/// ascending and disjoint, so only the run immediately left and right of `c`
596/// can be the nearest.
597fn near_island(c: u32, runs: &[(u32, u32)]) -> bool {
598 // First run whose start is strictly greater than `c` (the right neighbor).
599 let idx = runs.partition_point(|&(s, _)| s <= c);
600 if let Some(&(rs, _)) = runs.get(idx)
601 && rs - c <= SHORE_WIDTH_BP
602 {
603 return true;
604 }
605 if idx > 0 {
606 let (_, re) = runs[idx - 1];
607 // `re` is exclusive; the last island base is `re - 1`. `c >= re` here
608 // (otherwise `c` would be inside the run → classified Island already).
609 if c >= re && c - (re - 1) <= SHORE_WIDTH_BP {
610 return true;
611 }
612 }
613 false
614}
615
616/// Classify each CpG (given its top-strand C position) as island / shore /
617/// open-sea using the island `mask` produced by [`island_mask`].
618fn classify_cpg_contexts(cpg_top_c: &[u32], mask: &BitVec) -> Vec<CpgContext> {
619 let runs = island_runs(mask);
620 cpg_top_c
621 .iter()
622 .map(|&c| {
623 if mask.get(c as usize).is_some_and(|b| *b) {
624 CpgContext::Island
625 } else if near_island(c, &runs) {
626 CpgContext::Shore
627 } else {
628 CpgContext::OpenSea
629 }
630 })
631 .collect()
632}
633
634/// Apply per-base methylation chemistry conversion to read bases in place.
635///
636/// `bases` are in read 5'->3' orientation (FASTQ orientation). `n_genomic`
637/// is the number of leading bases that come from the haplotype (anything
638/// past `n_genomic` is adapter sequence and is left untouched). When
639/// `is_negative_strand` is `true`, `bases[i]` covers haplotype position
640/// `hap_start + (n_genomic - 1 - i)`; otherwise `hap_start + i`.
641///
642/// `haplotype_index` selects which per-haplotype methylation table to use
643/// from the [`ContigMethylation`] in `config`.
644///
645/// Per-molecule conversion failure is drawn once here, before the per-base
646/// loop: with probability `config.failure_rate` the molecule is a failure
647/// and converts its should-convert cytosines at `1.0 - config.conversion_rate`
648/// (near-zero), otherwise at `config.conversion_rate`. Because this runs once
649/// per fragment (via [`crate::read::apply_fragment_chemistry`]), both mates
650/// derive from the same converted buffer and stay coherent. Returns whether
651/// the molecule was drawn as a conversion failure so the caller can record it
652/// as ground truth.
653///
654/// Called between `uppercase_in_place` and `apply_errors` in
655/// [`crate::read::generate_read_pair`]'s `build_mate` helper. Chemistry
656/// runs before sequencing errors are applied (mirroring the biological
657/// order).
658///
659/// # Panics
660///
661/// Panics if `config.conversion_rate` or `config.failure_rate` is not a
662/// finite value in `[0.0, 1.0]`. Mirrors the guard on
663/// [`MethylationTable::from_haplotype`]: the CLI validates this, but the
664/// function is `pub`, and an unguarded `NaN` / out-of-range rate would
665/// silently distort every chemistry draw.
666pub fn apply_methylation_conversion(
667 bases: &mut [u8],
668 n_genomic: usize,
669 is_negative_strand: bool,
670 hap_start: u32,
671 haplotype_index: usize,
672 config: &MethylationConfig<'_>,
673 rng: &mut impl Rng,
674) -> bool {
675 // Validate at the public boundary (see `from_haplotype` for the same
676 // guard). An out-of-range / NaN rate would silently corrupt the
677 // `rng < rate` decision rather than fail loudly.
678 assert!(
679 config.conversion_rate.is_finite() && (0.0..=1.0).contains(&config.conversion_rate),
680 "conversion_rate must be a finite value in [0.0, 1.0]; got {}",
681 config.conversion_rate,
682 );
683 assert!(
684 config.failure_rate.is_finite() && (0.0..=1.0).contains(&config.failure_rate),
685 "failure_rate must be a finite value in [0.0, 1.0]; got {}",
686 config.failure_rate,
687 );
688 // Draw the per-molecule camp once, before the per-base loop. A failed
689 // molecule converts at `1 - conversion_rate` (near-zero), retaining
690 // essentially all of its should-convert cytosines as a coherent unit.
691 // Only consume an RNG draw when failures are enabled.
692 let conversion_failed = config.failure_rate > 0.0 && rng.random::<f64>() < config.failure_rate;
693 let rate =
694 if conversion_failed { 1.0 - config.conversion_rate } else { config.conversion_rate };
695 let table = config.contig_methylation.table_for(haplotype_index);
696 for (i, base) in bases.iter_mut().enumerate().take(n_genomic) {
697 let b = *base;
698 if b != b'C' {
699 continue;
700 }
701 // `uppercase_in_place` runs before this function in the simulator
702 // pipeline, so lowercase 'c' is structurally impossible here.
703 debug_assert!(
704 !base.is_ascii_lowercase(),
705 "uppercase_in_place runs before apply_methylation_conversion"
706 );
707
708 let pos_idx = if is_negative_strand { n_genomic - 1 - i } else { i };
709 #[expect(clippy::cast_possible_truncation, reason = "pos_idx fits in u32")]
710 let hap_pos = hap_start + pos_idx as u32;
711 let is_meth = table.is_methylated(hap_pos, is_negative_strand);
712 let should_convert = match config.mode {
713 MethylationMode::EmSeq => !is_meth,
714 MethylationMode::Taps => is_meth,
715 };
716 if should_convert && rng.random::<f64>() < rate {
717 *base = b'T';
718 }
719 }
720 conversion_failed
721}
722
723/// Which methylation conversion pattern a read displays when mapped to the
724/// reference. Mirrors the Bismark `XG:Z` (genome-strand) tag convention --
725/// it's a strand indicator and stays the same for both em-seq and TAPS
726/// chemistries; only the biological meaning of an observed `C→T` differs
727/// (em-seq: unmethylated at that site; TAPS: methylated at that site).
728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729pub enum ConversionType {
730 /// Read derived from the top (forward) strand; shows `C->T` pattern when
731 /// mapped to the reference.
732 Ct,
733 /// Read derived from the bottom (reverse) strand; shows `G->A` pattern
734 /// when mapped to the reference.
735 Ga,
736}
737
738impl ConversionType {
739 /// Two-letter tag value (`"CT"` or `"GA"`) for the `XG:Z` BAM tag.
740 #[must_use]
741 pub fn as_tag_str(self) -> &'static str {
742 match self {
743 Self::Ct => "CT",
744 Self::Ga => "GA",
745 }
746 }
747
748 /// Top strand → `Ct`; bottom strand → `Ga`.
749 #[must_use]
750 pub fn from_strand(is_top: bool) -> Self {
751 if is_top { Self::Ct } else { Self::Ga }
752 }
753}
754
755/// Annotation captured during methylation read generation, used by
756/// downstream writers (notably the golden BAM) to emit the full Bismark-
757/// compatible methylation tag set: `XG:Z` (genome-strand indicator),
758/// `XR:Z` (read-conversion direction; derived from the SAM flag at
759/// emission time), `YS:Z` (pre-conversion bases), the holodeck `cf:i`
760/// conversion-failure flag, plus the Bismark call tags `XM:Z` / `YM:Z` /
761/// `NM:i` / `MD:Z` carried in `r1_call_tags` / `r2_call_tags` and computed
762/// by [`crate::methylation_tags::populate_pair_call_tags`].
763#[derive(Debug, Clone)]
764pub struct MethylationAnnotation {
765 /// Conversion direction for the source fragment (same for R1 and R2).
766 pub conversion_type: ConversionType,
767 /// Whether the source molecule was drawn as a conversion failure. A
768 /// molecule property, so it is identical for R1 and R2; surfaced in the
769 /// golden BAM as the `cf:i` ground-truth tag.
770 pub conversion_failed: bool,
771 /// R1 pre-conversion bases. `None` when `capture_pre_conversion` was
772 /// false at simulation time.
773 pub r1_pre_conversion_bases: Option<Vec<u8>>,
774 /// R2 pre-conversion bases. `None` for SE reads or when
775 /// `capture_pre_conversion` was false.
776 pub r2_pre_conversion_bases: Option<Vec<u8>>,
777 /// R1 Bismark-style methylation call tags (`XM`, `YM`, `NM`, `MD`).
778 /// Populated by the simulator when the golden BAM is requested.
779 pub r1_call_tags: Option<crate::methylation_tags::CallTags>,
780 /// R2 Bismark-style methylation call tags. `None` for SE reads or
781 /// when the golden BAM is not requested.
782 pub r2_call_tags: Option<crate::methylation_tags::CallTags>,
783}
784
785impl MethylationAnnotation {
786 /// Tags for the R1 BAM record. Returns `None` when the pre-conversion
787 /// bases were not captured (e.g., the user requested bisulfite
788 /// simulation but not a golden BAM).
789 #[must_use]
790 pub fn r1_tags(&self) -> Option<MethylationRecordTags<'_>> {
791 self.r1_pre_conversion_bases.as_deref().map(|bases| MethylationRecordTags {
792 conversion_type: self.conversion_type,
793 conversion_failed: self.conversion_failed,
794 pre_conversion_bases: bases,
795 call_tags: self.r1_call_tags.as_ref(),
796 })
797 }
798
799 /// Tags for the R2 BAM record. `None` for SE pairs OR when pre-conversion
800 /// bases were not captured.
801 #[must_use]
802 pub fn r2_tags(&self) -> Option<MethylationRecordTags<'_>> {
803 self.r2_pre_conversion_bases.as_deref().map(|bases| MethylationRecordTags {
804 conversion_type: self.conversion_type,
805 conversion_failed: self.conversion_failed,
806 pre_conversion_bases: bases,
807 call_tags: self.r2_call_tags.as_ref(),
808 })
809 }
810}
811
812/// Per-record methylation annotation passed into the golden BAM record
813/// builder when the pair was generated with methylation chemistry enabled
814/// (em-seq or TAPS). Carries the `XG:Z` strand indicator (shared across R1
815/// and R2) and the read's own pre-conversion bases in read 5'->3' (FASTQ)
816/// orientation. The record builder reverse-complements them when the record
817/// is reverse-strand so `YS:Z` ends up in the same orientation as `SEQ`.
818///
819/// `YS:Z` is a holodeck-specific BAM tag — no bisulfite aligner produces or
820/// consumes it. It exists so downstream evaluators can diff `SEQ` against
821/// `YS` base-for-base to recover ground-truth chemistry events.
822///
823/// `call_tags`, when present, carries the precomputed `XM:Z`, `YM:Z`,
824/// `NM:i`, and `MD:Z` values for the record.
825#[derive(Debug, Clone, Copy)]
826pub struct MethylationRecordTags<'a> {
827 /// Strand indicator for the `XG:Z` tag (same for R1 and R2 of a pair).
828 pub conversion_type: ConversionType,
829 /// Whether the source molecule was a conversion failure; emitted as the
830 /// `cf:i` ground-truth tag. Same for R1 and R2 of a pair.
831 pub conversion_failed: bool,
832 /// Pre-conversion bases (read 5'->3' orientation) for the `YS:Z` tag.
833 pub pre_conversion_bases: &'a [u8],
834 /// Precomputed Bismark methylation call tags. `None` when the
835 /// simulator did not compute them (no golden BAM requested).
836 pub call_tags: Option<&'a crate::methylation_tags::CallTags>,
837}
838
839#[cfg(test)]
840mod tests {
841 use rand::SeedableRng;
842 use rand::rngs::SmallRng;
843
844 use super::*;
845 use crate::haplotype::build_haplotypes;
846 use crate::vcf::genotype::{Genotype, VariantRecord};
847
848 #[test]
849 fn test_find_reference_cpgs_basic() {
850 // Reference "ACGTACG" → CpGs at positions 1 and 5.
851 assert_eq!(find_reference_cpgs(b"ACGTACG"), vec![1, 5]);
852 }
853
854 #[test]
855 fn test_find_reference_cpgs_case_insensitive() {
856 assert_eq!(find_reference_cpgs(b"acgTaCg"), vec![1, 5]);
857 }
858
859 #[test]
860 fn test_find_reference_cpgs_empty_and_short() {
861 assert!(find_reference_cpgs(b"").is_empty());
862 assert!(find_reference_cpgs(b"C").is_empty());
863 assert!(find_reference_cpgs(b"AT").is_empty());
864 assert_eq!(find_reference_cpgs(b"CG"), vec![0]);
865 }
866
867 /// Build a [`MethylationConfig`] wrapping a single-haplotype
868 /// [`ContigMethylation`] for chemistry-only invocation tests. Returns
869 /// the owning `ContigMethylation` so the borrow lives long enough.
870 fn single_hap_cm(table: MethylationTable) -> ContigMethylation {
871 ContigMethylation::from_tables(vec![table])
872 }
873
874 /// Build a single all-reference haplotype (no variants).
875 fn ref_haplotype() -> crate::haplotype::Haplotype {
876 let haps = build_haplotypes(&[], 1, &mut SmallRng::seed_from_u64(0));
877 haps.into_iter().next().unwrap()
878 }
879
880 /// Build a SNP variant record helper for tests.
881 fn snp_variant(pos: u32, ref_base: u8, alt_base: u8, gt: &str) -> VariantRecord {
882 VariantRecord {
883 position: pos,
884 ref_allele: vec![ref_base],
885 alt_alleles: vec![vec![alt_base]],
886 genotype: Genotype::parse(gt).unwrap(),
887 }
888 }
889
890 /// Build an indel variant record helper for tests.
891 fn indel_variant(pos: u32, ref_allele: &[u8], alt_allele: &[u8], gt: &str) -> VariantRecord {
892 VariantRecord {
893 position: pos,
894 ref_allele: ref_allele.to_vec(),
895 alt_alleles: vec![alt_allele.to_vec()],
896 genotype: Genotype::parse(gt).unwrap(),
897 }
898 }
899
900 /// A [`MethylationModel`] with all three contexts sharing `rate`,
901 /// correlation length `l`, and hemimethylation `hemi`. Lets tests isolate
902 /// the stationary-mean, autocorrelation, and hemi behaviours independently
903 /// of context classification.
904 fn model(rate: f64, l: f64, hemi: f64) -> MethylationModel {
905 let ctx = ContextParams { rate, correlation_length_bp: l };
906 MethylationModel { island: ctx, shore: ctx, open_sea: ctx, hemi_rate: hemi }
907 }
908
909 /// A [`MethylationModel`] with all three contexts at the same `rate`,
910 /// default correlation length, and no hemimethylation. At `rate` 1.0/0.0
911 /// the walk is fully deterministic (every / no CpG methylated, symmetric),
912 /// so tests can pin exact bits regardless of context or correlation.
913 fn uniform_model(rate: f64) -> MethylationModel {
914 model(rate, DEFAULT_CORRELATION_LENGTH_BP, 0.0)
915 }
916
917 /// Build a reference of `units` copies of "ACGT" — one CpG every 4 bp
918 /// (top-strand C at positions 1, 5, 9, …). Used by the statistical walk
919 /// tests; "ACGT" is 50% GC so these CpGs classify as open-sea.
920 fn acgt_repeat(units: usize) -> Vec<u8> {
921 b"ACGT".repeat(units)
922 }
923
924 // --- from_haplotype tests ---
925
926 #[test]
927 fn test_from_haplotype_matches_reference_for_no_variants_haplotype() {
928 // No variants → haplotype 0 is the reference. The bitmap shape and
929 // the methylation marks should match a direct CpG scan of the ref.
930 let reference = b"ACGTACGT";
931 let hap = ref_haplotype();
932 let mut rng = SmallRng::seed_from_u64(42);
933 let table = MethylationTable::from_haplotype(
934 hap_borrow(&hap),
935 reference,
936 &uniform_model(1.0),
937 &mut rng,
938 );
939
940 assert_eq!(table.len(), reference.len());
941 for i in 0u32..8 {
942 let want_top = i == 1 || i == 5;
943 let want_bottom = i == 2 || i == 6;
944 assert_eq!(table.is_methylated(i, false), want_top, "top[{i}] mismatch");
945 assert_eq!(table.is_methylated(i, true), want_bottom, "bottom[{i}] mismatch");
946 }
947 }
948
949 /// Tiny shim so the no-variants-haplotype helper can be reused without
950 /// transferring ownership for each call site.
951 fn hap_borrow(h: &crate::haplotype::Haplotype) -> &crate::haplotype::Haplotype {
952 h
953 }
954
955 #[test]
956 fn test_from_haplotype_snp_creates_cpg() {
957 // Reference ATG → SNP T→C at pos 1 on the variant haplotype gives
958 // ACG, which has a CpG at hap positions (1, 2). With rate 1.0 both
959 // strand bits should be set for haplotype 1.
960 let reference = b"ATG";
961 let variants = vec![snp_variant(1, b'T', b'C', "0|1")];
962 let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(7));
963 let mut rng = SmallRng::seed_from_u64(42);
964 let var_hap_table =
965 MethylationTable::from_haplotype(&haps[1], reference, &uniform_model(1.0), &mut rng);
966
967 assert_eq!(var_hap_table.len(), 3);
968 assert!(var_hap_table.is_methylated(1, false), "top-strand C at hap pos 1 must be set");
969 assert!(var_hap_table.is_methylated(2, true), "bottom-strand C at hap pos 2 must be set");
970
971 // Reference haplotype: no CpG at all.
972 let mut rng2 = SmallRng::seed_from_u64(42);
973 let ref_hap_table =
974 MethylationTable::from_haplotype(&haps[0], reference, &uniform_model(1.0), &mut rng2);
975 assert!(!ref_hap_table.is_methylated(1, false));
976 assert!(!ref_hap_table.is_methylated(2, true));
977 }
978
979 #[test]
980 fn test_from_haplotype_snp_destroys_cpg() {
981 // Reference ACG (CpG at positions 1-2) → SNP C→T at pos 1 on the
982 // variant haplotype gives ATG, no CpG. No methylation marks
983 // anywhere on hap 1.
984 let reference = b"ACG";
985 let variants = vec![snp_variant(1, b'C', b'T', "0|1")];
986 let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(7));
987 let mut rng = SmallRng::seed_from_u64(42);
988 let table =
989 MethylationTable::from_haplotype(&haps[1], reference, &uniform_model(1.0), &mut rng);
990
991 assert!(!table.is_methylated(0, false));
992 assert!(!table.is_methylated(1, false));
993 assert!(!table.is_methylated(2, false));
994 assert!(!table.is_methylated(0, true));
995 assert!(!table.is_methylated(1, true));
996 assert!(!table.is_methylated(2, true));
997 }
998
999 #[test]
1000 fn test_from_haplotype_deletion_bridges_cpg() {
1001 // Reference CTG: no CpG. Delete the T (CT → C, anchor at pos 0)
1002 // → haplotype CG with hap positions 0 (C) and 1 (G).
1003 // Assert top[0] and bottom[1] are both set.
1004 let reference = b"CTG";
1005 // VCF deletion: REF=CT (positions 0..2) → ALT=C; net -1 base.
1006 let variants = vec![indel_variant(0, b"CT", b"C", "0|1")];
1007 let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(7));
1008 let mut rng = SmallRng::seed_from_u64(42);
1009 let table =
1010 MethylationTable::from_haplotype(&haps[1], reference, &uniform_model(1.0), &mut rng);
1011
1012 // Materialized hap is "CG" (length 2).
1013 assert_eq!(table.len(), 2);
1014 assert!(table.is_methylated(0, false), "top-strand C at hap pos 0 must be set");
1015 assert!(table.is_methylated(1, true), "bottom-strand C at hap pos 1 must be set");
1016 }
1017
1018 #[test]
1019 fn test_from_haplotype_inserted_cpg() {
1020 // Reference AG: no CpG. Insertion of C between A and G:
1021 // VCF style: REF=A (pos 0) → ALT=AC. After insertion the
1022 // haplotype reads "ACG" with hap positions 0 (A), 1 (C), 2 (G).
1023 // Assert top[1] and bottom[2] are set on the variant haplotype.
1024 let reference = b"AG";
1025 let variants = vec![indel_variant(0, b"A", b"AC", "0|1")];
1026 let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(7));
1027 let mut rng = SmallRng::seed_from_u64(42);
1028 let table =
1029 MethylationTable::from_haplotype(&haps[1], reference, &uniform_model(1.0), &mut rng);
1030
1031 assert_eq!(table.len(), 3, "haplotype should be 3 bases (ACG)");
1032 assert!(table.is_methylated(1, false), "inserted top-strand C must be methylated");
1033 assert!(table.is_methylated(2, true), "bottom-strand C at G must be methylated");
1034 }
1035
1036 #[test]
1037 fn test_from_haplotype_multi_base_insertion_not_truncated() {
1038 // Reference AG (length 2). Insertion: A → ACGCG (alt_len=5, ref_len=1,
1039 // net delta = +4). Materialized haplotype is "ACGCGG" (length 6) with
1040 // CpGs at hap positions (1,2) and (3,4). Both pairs must register —
1041 // a previous over-tight cap truncated tail bases past
1042 // reference.len() + 1, losing the second CpG entirely.
1043 let reference = b"AG";
1044 let variants = vec![indel_variant(0, b"A", b"ACGCG", "0|1")];
1045 let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(7));
1046 let mut rng = SmallRng::seed_from_u64(42);
1047 let table =
1048 MethylationTable::from_haplotype(&haps[1], reference, &uniform_model(1.0), &mut rng);
1049
1050 assert_eq!(table.len(), 6, "haplotype must materialize all 6 bases (ACGCGG)");
1051 assert!(table.is_methylated(1, false), "top[1] must be set (first CpG)");
1052 assert!(table.is_methylated(2, true), "bottom[2] must be set (first CpG)");
1053 assert!(
1054 table.is_methylated(3, false),
1055 "top[3] must be set (second CpG, lost when truncated)"
1056 );
1057 assert!(
1058 table.is_methylated(4, true),
1059 "bottom[4] must be set (second CpG, lost when truncated)"
1060 );
1061 }
1062
1063 #[test]
1064 #[should_panic(expected = "invalid MethylationModel")]
1065 fn test_from_haplotype_rejects_nan_rate() {
1066 let reference = b"ACGT";
1067 let hap = ref_haplotype();
1068 let mut rng = SmallRng::seed_from_u64(42);
1069 let _ =
1070 MethylationTable::from_haplotype(&hap, reference, &uniform_model(f64::NAN), &mut rng);
1071 }
1072
1073 #[test]
1074 #[should_panic(expected = "invalid MethylationModel")]
1075 fn test_from_haplotype_rejects_rate_above_one() {
1076 let reference = b"ACGT";
1077 let hap = ref_haplotype();
1078 let mut rng = SmallRng::seed_from_u64(42);
1079 let _ = MethylationTable::from_haplotype(&hap, reference, &uniform_model(1.5), &mut rng);
1080 }
1081
1082 #[test]
1083 fn test_methylation_model_validate_rejects_bad_fields() {
1084 // Each invalid field is named in the error message.
1085 let mut m = uniform_model(0.5);
1086 m.island.rate = 1.5;
1087 assert!(format!("{}", m.validate().unwrap_err()).contains("--methylation-rate-island"));
1088
1089 let mut m = uniform_model(0.5);
1090 m.shore.correlation_length_bp = 0.0;
1091 assert!(
1092 format!("{}", m.validate().unwrap_err())
1093 .contains("--methylation-correlation-length-shore")
1094 );
1095
1096 let mut m = uniform_model(0.5);
1097 m.hemi_rate = f64::NAN;
1098 assert!(format!("{}", m.validate().unwrap_err()).contains("--hemimethylation-rate"));
1099 }
1100
1101 #[test]
1102 fn test_from_haplotype_zero_rate_no_methylation() {
1103 let reference = b"ACGTACGTACGT";
1104 let hap = ref_haplotype();
1105 let mut rng = SmallRng::seed_from_u64(42);
1106 let table =
1107 MethylationTable::from_haplotype(&hap, reference, &uniform_model(0.0), &mut rng);
1108 #[expect(clippy::cast_possible_truncation, reason = "test reference len fits in u32")]
1109 let len = reference.len() as u32;
1110 for i in 0..len {
1111 assert!(!table.is_methylated(i, false));
1112 assert!(!table.is_methylated(i, true));
1113 }
1114 }
1115
1116 #[test]
1117 fn test_from_haplotype_case_insensitive() {
1118 // Lowercase "acgt" should still detect a CpG at (1, 2).
1119 let reference = b"acgt";
1120 let hap = ref_haplotype();
1121 let mut rng = SmallRng::seed_from_u64(42);
1122 let table =
1123 MethylationTable::from_haplotype(&hap, reference, &uniform_model(1.0), &mut rng);
1124 assert!(table.is_methylated(1, false), "lowercase 'cg' must register top-strand C");
1125 assert!(table.is_methylated(2, true), "lowercase 'cg' must register bottom-strand C");
1126 assert!(!table.is_methylated(0, false));
1127 assert!(!table.is_methylated(3, true));
1128 }
1129
1130 #[test]
1131 fn test_from_haplotype_symmetric_by_default() {
1132 // With hemi_rate 0 every methylated CpG sets BOTH strands and every
1133 // unmethylated CpG sets neither — no hemimethylation. Replaces the old
1134 // independent-strand-draw test: the model is now symmetric by default.
1135 let reference = acgt_repeat(200);
1136 let hap = ref_haplotype();
1137 let mut rng = SmallRng::seed_from_u64(7);
1138 let table =
1139 MethylationTable::from_haplotype(&hap, &reference, &model(0.5, 1000.0, 0.0), &mut rng);
1140 for site in 0u32..200 {
1141 let top = table.is_methylated(site * 4 + 1, false);
1142 let bot = table.is_methylated(site * 4 + 2, true);
1143 assert_eq!(top, bot, "site {site}: strands must agree with hemi_rate 0");
1144 }
1145 }
1146
1147 #[test]
1148 fn test_walk_stationary_mean_matches_rate() {
1149 // Tiny correlation length → near-independent draws, so the mean over
1150 // many CpGs converges to the target rate. Decouples the stationary
1151 // distribution from the correlation knob. Empirical band, seed-pinned
1152 // (SmallRng on rand 0.9); widen if the RNG stream changes.
1153 let reference = acgt_repeat(5000); // 5000 CpGs, spaced 4 bp
1154 let hap = ref_haplotype();
1155 let mut rng = SmallRng::seed_from_u64(42);
1156 let table =
1157 MethylationTable::from_haplotype(&hap, &reference, &model(0.3, 1.0, 0.0), &mut rng);
1158 let meth = (0u32..5000).filter(|&s| table.is_methylated(s * 4 + 1, false)).count();
1159 let frac = meth as f64 / 5000.0;
1160 assert!((0.27..=0.33).contains(&frac), "stationary mean {frac} should be ~0.3");
1161 }
1162
1163 #[test]
1164 fn test_walk_autocorrelation_present_with_long_l_absent_with_short_l() {
1165 let reference = acgt_repeat(4000);
1166 let hap = ref_haplotype();
1167 let agreement = |l: f64, seed: u64| {
1168 let mut rng = SmallRng::seed_from_u64(seed);
1169 let table =
1170 MethylationTable::from_haplotype(&hap, &reference, &model(0.5, l, 0.0), &mut rng);
1171 let states: Vec<bool> =
1172 (0u32..4000).map(|s| table.is_methylated(s * 4 + 1, false)).collect();
1173 let agree = states.windows(2).filter(|w| w[0] == w[1]).count();
1174 agree as f64 / (states.len() - 1) as f64
1175 };
1176 // Long L (>> 4 bp spacing) → neighbours almost always agree.
1177 assert!(agreement(10_000.0, 1) > 0.9, "long correlation length should give long runs");
1178 // Tiny L → near-independent → agreement ~0.5 at p=0.5.
1179 let indep = agreement(1.0, 2);
1180 assert!((0.42..=0.58).contains(&indep), "tiny L should decorrelate neighbours: {indep}");
1181 }
1182
1183 #[test]
1184 fn test_walk_hemi_rate_produces_hemimethylation() {
1185 // rate 1.0 → every CpG methylated; hemi 0.3 → ~30% become hemi (one
1186 // strand). Both strand-drop directions should occur. Seed-pinned band.
1187 let reference = acgt_repeat(3000);
1188 let hap = ref_haplotype();
1189 let mut rng = SmallRng::seed_from_u64(42);
1190 let table =
1191 MethylationTable::from_haplotype(&hap, &reference, &model(1.0, 1.0, 0.3), &mut rng);
1192 let (mut hemi, mut top_only, mut bot_only) = (0usize, 0usize, 0usize);
1193 for s in 0u32..3000 {
1194 let top = table.is_methylated(s * 4 + 1, false);
1195 let bot = table.is_methylated(s * 4 + 2, true);
1196 if top != bot {
1197 hemi += 1;
1198 if top {
1199 top_only += 1;
1200 } else {
1201 bot_only += 1;
1202 }
1203 }
1204 }
1205 let frac = hemi as f64 / 3000.0;
1206 assert!((0.25..=0.35).contains(&frac), "hemi fraction {frac} should be ~0.3");
1207 assert!(top_only > 100 && bot_only > 100, "both hemi directions should occur");
1208 }
1209
1210 #[test]
1211 fn test_walk_island_hypomethylated_relative_to_open_sea() {
1212 // Build a CpG island ("CG" repeats, 100% GC, CpG-dense) embedded in a
1213 // long AT-rich open-sea background (sparse CpGs, 20% GC). With the
1214 // realistic context defaults the island CpGs should be markedly less
1215 // methylated than the far open-sea CpGs. Small L so each region's mean
1216 // converges rather than locking into one long run.
1217 let os_unit = b"AATTCGAATT"; // CG at offset 4, 20% GC → open-sea
1218 let os_units = 2000usize; // 20 kb each flank
1219 let island_units = 1500usize; // 3 kb island of "CG"
1220 let mut reference = Vec::new();
1221 for _ in 0..os_units {
1222 reference.extend_from_slice(os_unit);
1223 }
1224 let island_start = reference.len();
1225 for _ in 0..island_units {
1226 reference.extend_from_slice(b"CG");
1227 }
1228 let island_end = reference.len();
1229 for _ in 0..os_units {
1230 reference.extend_from_slice(os_unit);
1231 }
1232
1233 let hap = ref_haplotype();
1234 let mut rng = SmallRng::seed_from_u64(42);
1235 let m = MethylationModel {
1236 island: ContextParams { rate: 0.1, correlation_length_bp: 10.0 },
1237 shore: ContextParams { rate: 0.5, correlation_length_bp: 10.0 },
1238 open_sea: ContextParams { rate: 0.85, correlation_length_bp: 10.0 },
1239 hemi_rate: 0.0,
1240 };
1241 let table = MethylationTable::from_haplotype(&hap, &reference, &m, &mut rng);
1242
1243 let cpgs = find_reference_cpgs(&reference);
1244 let (mut isl_m, mut isl_n, mut sea_m, mut sea_n) = (0usize, 0usize, 0usize, 0usize);
1245 for &c in &cpgs {
1246 let cu = c as usize;
1247 let methylated = table.is_methylated(c, false);
1248 // Deep island interior vs open-sea well beyond the 2 kb shore +
1249 // relaxation band — avoids transition-band ambiguity.
1250 if cu >= island_start + 200 && cu < island_end - 200 {
1251 isl_n += 1;
1252 isl_m += usize::from(methylated);
1253 } else if cu + 6000 < island_start || cu > island_end + 6000 {
1254 sea_n += 1;
1255 sea_m += usize::from(methylated);
1256 }
1257 }
1258 let isl_frac = isl_m as f64 / isl_n as f64;
1259 let sea_frac = sea_m as f64 / sea_n as f64;
1260 assert!(isl_frac < 0.35, "island interior should be hypomethylated, got {isl_frac}");
1261 assert!(sea_frac > 0.65, "open sea should be hypermethylated, got {sea_frac}");
1262 assert!(isl_frac < sea_frac, "island must be less methylated than open sea");
1263 }
1264
1265 #[test]
1266 fn test_walk_determinism_fixed_seed() {
1267 let reference = acgt_repeat(500);
1268 let hap = ref_haplotype();
1269 let m = model(0.6, 500.0, 0.05);
1270 let run = || {
1271 let mut rng = SmallRng::seed_from_u64(123);
1272 let t = MethylationTable::from_haplotype(&hap, &reference, &m, &mut rng);
1273 (0u32..2000)
1274 .map(|p| (t.is_methylated(p, false), t.is_methylated(p, true)))
1275 .collect::<Vec<_>>()
1276 };
1277 assert_eq!(run(), run(), "same seed must produce identical methylation");
1278 }
1279
1280 #[test]
1281 fn test_walk_no_cpg_and_single_cpg_do_not_panic() {
1282 let hap = ref_haplotype();
1283 let mut rng = SmallRng::seed_from_u64(1);
1284 // No CpG anywhere.
1285 let t = MethylationTable::from_haplotype(&hap, b"AAAATTTT", &uniform_model(1.0), &mut rng);
1286 for i in 0..8 {
1287 assert!(!t.is_methylated(i, false) && !t.is_methylated(i, true));
1288 }
1289 // Single CpG, full methylation → both strands set.
1290 let t = MethylationTable::from_haplotype(&hap, b"ACGT", &uniform_model(1.0), &mut rng);
1291 assert!(t.is_methylated(1, false) && t.is_methylated(2, true));
1292 }
1293
1294 #[test]
1295 fn test_walk_shore_rate_is_intermediate_between_island_and_open_sea() {
1296 // Guards the Shore branch of `params_for`: shore CpGs (within 2 kb of
1297 // the island, but outside it) must take the shore rate, landing
1298 // between island (hypo) and open-sea (hyper). A bug routing Shore to
1299 // the wrong ContextParams would otherwise pass every other test, which
1300 // all exclude the shore band. Distinct rates + small L for tight means.
1301 let os_unit = b"AATTCGAATT"; // CG every 10 bp, 20% GC → open-sea
1302 let mut seq = b"CG".repeat(150); // 300 bp island at the start
1303 let island_end = seq.len();
1304 for _ in 0..1500 {
1305 seq.extend_from_slice(os_unit); // 15 kb of CpG-bearing open-sea
1306 }
1307 let hap = ref_haplotype();
1308 let mut rng = SmallRng::seed_from_u64(42);
1309 let m = MethylationModel {
1310 island: ContextParams { rate: 0.1, correlation_length_bp: 30.0 },
1311 shore: ContextParams { rate: 0.5, correlation_length_bp: 30.0 },
1312 open_sea: ContextParams { rate: 0.85, correlation_length_bp: 30.0 },
1313 hemi_rate: 0.0,
1314 };
1315 let table = MethylationTable::from_haplotype(&hap, &seq, &m, &mut rng);
1316
1317 let cpgs = find_reference_cpgs(&seq);
1318 let mean_over = |lo: usize, hi: usize| {
1319 let v: Vec<bool> = cpgs
1320 .iter()
1321 .filter(|&&c| (c as usize) >= lo && (c as usize) < hi)
1322 .map(|&c| table.is_methylated(c, false))
1323 .collect();
1324 assert!(!v.is_empty(), "no CpGs in [{lo}, {hi})");
1325 v.iter().filter(|&&b| b).count() as f64 / v.len() as f64
1326 };
1327 // Island interior; shore band (within 2 kb, past the relaxation zone);
1328 // far open sea (> 2 kb past the island).
1329 let island = mean_over(50, island_end - 20);
1330 let shore = mean_over(island_end + 400, island_end + 1900);
1331 let open_sea = mean_over(island_end + 6000, seq.len());
1332 assert!(island < shore, "island {island} should be below shore {shore}");
1333 assert!(shore < open_sea, "shore {shore} should be below open sea {open_sea}");
1334 assert!((0.3..=0.7).contains(&shore), "shore mean {shore} should be ~0.5");
1335 }
1336
1337 #[test]
1338 fn test_from_haplotypes_allele_specific_methylation() {
1339 // Two haplotypes with IDENTICAL CpG content (no variants → both are the
1340 // reference) must receive DIFFERENT methylation patterns, because each
1341 // haplotype is walked as an independent chain. Pins the advertised
1342 // allele-specific-methylation behaviour, which the rate-1.0/0.0 tests
1343 // (deterministic) cannot exercise.
1344 let reference = acgt_repeat(2000); // 2000 CpGs, identical on both haps
1345 let haps = build_haplotypes(&[], 2, &mut SmallRng::seed_from_u64(0));
1346 let mut rng = SmallRng::seed_from_u64(42);
1347 let cm =
1348 ContigMethylation::from_haplotypes(&haps, &reference, &model(0.5, 1.0, 0.0), &mut rng);
1349 let (h0, h1) = (cm.table_for(0), cm.table_for(1));
1350 let differ = (0u32..2000)
1351 .filter(|&s| h0.is_methylated(s * 4 + 1, false) != h1.is_methylated(s * 4 + 1, false))
1352 .count();
1353 // Independent Bernoulli(0.5) draws differ ~50% of the time; require a
1354 // large fraction to rule out shared/duplicated draws across haplotypes.
1355 assert!(
1356 differ > 600,
1357 "haplotypes should diverge at many CpGs; only {differ}/2000 differed"
1358 );
1359 }
1360
1361 // --- CpG-island detector tests ---
1362
1363 #[test]
1364 fn test_island_mask_detects_gc_cpg_dense_block() {
1365 // 300 bp "CG" island flanked by AT-rich sequence.
1366 let mut seq = b"AT".repeat(400); // 800 bp AT
1367 let island_start = seq.len();
1368 seq.extend_from_slice(&b"CG".repeat(150)); // 300 bp island
1369 let island_end = seq.len();
1370 seq.extend_from_slice(&b"AT".repeat(400));
1371 let mask = island_mask(&seq);
1372 // Interior of the CG block is island; AT flanks are not.
1373 assert!(mask[island_start + 150], "CG-dense interior should be island");
1374 assert!(!mask[10], "AT-rich flank should not be island");
1375 assert!(!mask[island_end + 400], "far AT flank should not be island");
1376 }
1377
1378 #[test]
1379 fn test_island_mask_none_in_at_rich_or_short() {
1380 assert!(island_mask(&b"AT".repeat(500)).not_any(), "AT-rich → no island");
1381 assert!(island_mask(b"CGCGCG").not_any(), "sub-window sequence → no island");
1382 }
1383
1384 #[test]
1385 fn test_classify_cpg_contexts_island_shore_open_sea() {
1386 // Island of CG at the start, then a long AT-rich tail with sparse CpGs.
1387 let mut seq = b"CG".repeat(150); // 300 bp island
1388 let island_end = seq.len();
1389 seq.extend_from_slice(&b"AATTCGAATT".repeat(1000)); // CpGs every 10 bp out to ~10 kb
1390 let cpgs = find_reference_cpgs(&seq);
1391 let mask = island_mask(&seq);
1392 let contexts = classify_cpg_contexts(&cpgs, &mask);
1393 // First CpG (inside island) is Island.
1394 assert_eq!(contexts[0], CpgContext::Island);
1395 // A CpG ~1 kb past the island is Shore; one ~5 kb past is OpenSea.
1396 let ctx_at = |target: usize| {
1397 let idx = cpgs.iter().position(|&c| c as usize >= target).unwrap();
1398 contexts[idx]
1399 };
1400 assert_eq!(ctx_at(island_end + 1000), CpgContext::Shore, "within 2 kb → shore");
1401 assert_eq!(ctx_at(island_end + 5000), CpgContext::OpenSea, "beyond 2 kb → open sea");
1402 }
1403
1404 #[test]
1405 fn test_from_haplotype_empty_and_short() {
1406 let hap = ref_haplotype();
1407 let mut rng = SmallRng::seed_from_u64(42);
1408 let table = MethylationTable::from_haplotype(&hap, b"", &uniform_model(1.0), &mut rng);
1409 assert!(table.is_empty());
1410 assert_eq!(table.len(), 0);
1411
1412 let table = MethylationTable::from_haplotype(&hap, b"C", &uniform_model(1.0), &mut rng);
1413 assert_eq!(table.len(), 1);
1414 assert!(!table.is_methylated(0, false));
1415 assert!(!table.is_methylated(0, true));
1416 }
1417
1418 #[test]
1419 fn test_is_methylated_strand_selection() {
1420 let mut table = MethylationTable::empty(10);
1421 table.set_top(3, true);
1422 table.set_bottom(7, true);
1423 assert!(table.is_methylated(3, false));
1424 assert!(!table.is_methylated(3, true));
1425 assert!(!table.is_methylated(7, false));
1426 assert!(table.is_methylated(7, true));
1427 }
1428
1429 #[test]
1430 fn test_is_methylated_out_of_range() {
1431 let table = MethylationTable::empty(10);
1432 assert!(!table.is_methylated(99, false));
1433 assert!(!table.is_methylated(99, true));
1434 assert!(!table.is_methylated(u32::MAX, false));
1435 }
1436
1437 #[test]
1438 fn test_empty_table_returns_false_everywhere() {
1439 let table = MethylationTable::empty(100);
1440 assert_eq!(table.len(), 100);
1441 assert!(!table.is_empty());
1442 for i in 0..100 {
1443 assert!(!table.is_methylated(i, false));
1444 assert!(!table.is_methylated(i, true));
1445 }
1446 }
1447
1448 // --- ContigMethylation tests ---
1449
1450 #[test]
1451 fn test_contig_methylation_per_haplotype_independent() {
1452 // Two haplotypes with different CpG content. ContigMethylation
1453 // should keep their bitmaps separate.
1454 let reference = b"ACG"; // hap 0 = ref ACG, hap 1 = AAG (SNP C→A at pos 1)
1455 let variants = vec![snp_variant(1, b'C', b'A', "0|1")];
1456 let haps = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(0));
1457 let mut rng = SmallRng::seed_from_u64(42);
1458 let cm =
1459 ContigMethylation::from_haplotypes(&haps, reference, &uniform_model(1.0), &mut rng);
1460
1461 assert_eq!(cm.len(), 2);
1462 assert!(!cm.is_empty());
1463
1464 // Haplotype 0 (ref): CpG at hap_pos (1, 2).
1465 let t0 = cm.table_for(0);
1466 assert!(t0.is_methylated(1, false));
1467 assert!(t0.is_methylated(2, true));
1468
1469 // Haplotype 1 (AAG): no CpG.
1470 let t1 = cm.table_for(1);
1471 assert!(!t1.is_methylated(1, false));
1472 assert!(!t1.is_methylated(2, true));
1473 }
1474
1475 // --- apply_methylation_conversion tests (em-seq mode) ---
1476
1477 #[test]
1478 fn test_apply_methylation_conversion_em_seq_zero_meth_full_conversion_forward() {
1479 let cm = single_hap_cm(MethylationTable::empty(20));
1480 let mut bases = b"ACGTACGT".to_vec();
1481 let mut rng = SmallRng::seed_from_u64(42);
1482
1483 let c = MethylationConfig {
1484 contig_methylation: &cm,
1485 mode: MethylationMode::EmSeq,
1486 conversion_rate: 1.0,
1487 failure_rate: 0.0,
1488 };
1489 apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
1490
1491 // 0% methylated, 100% conversion rate -> every C becomes T.
1492 assert_eq!(&bases, b"ATGTATGT");
1493 }
1494
1495 #[test]
1496 fn test_apply_methylation_conversion_em_seq_full_meth_no_conversion() {
1497 // C's in "ACGTACGT" at read positions 1 and 5 cover hap positions
1498 // 11 and 15 (hap_start = 10). Set the top-strand bits directly so
1499 // they're protected.
1500 let mut table = MethylationTable::empty(20);
1501 table.set_top(11, true);
1502 table.set_top(15, true);
1503 let cm = single_hap_cm(table);
1504
1505 let mut bases = b"ACGTACGT".to_vec();
1506 let mut rng = SmallRng::seed_from_u64(42);
1507
1508 let c = MethylationConfig {
1509 contig_methylation: &cm,
1510 mode: MethylationMode::EmSeq,
1511 conversion_rate: 1.0,
1512 failure_rate: 0.0,
1513 };
1514 apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
1515
1516 // Both C's are methylated -> no conversion, even at full rate.
1517 assert_eq!(&bases, b"ACGTACGT");
1518 }
1519
1520 #[test]
1521 fn test_apply_methylation_conversion_em_seq_negative_strand_uses_reversed_index() {
1522 // Methylate ONLY hap position 17 on the bottom strand.
1523 let mut table = MethylationTable::empty(20);
1524 table.set_bottom(17, true);
1525 let cm = single_hap_cm(table);
1526
1527 // For a negative-strand read, bases[0] covers hap_start + (n-1).
1528 // hap_start = 13, n = 5 → bases[0] covers hap_pos 17.
1529 // The C at read position 0 should look up hap pos 17 and find
1530 // 100% methylation (on the bottom strand), so it must NOT convert.
1531 // The C at read position 4 (covering hap pos 13) is not methylated
1532 // → converts.
1533 let mut bases = b"CAAAC".to_vec();
1534 let mut rng = SmallRng::seed_from_u64(42);
1535
1536 let c = MethylationConfig {
1537 contig_methylation: &cm,
1538 mode: MethylationMode::EmSeq,
1539 conversion_rate: 1.0,
1540 failure_rate: 0.0,
1541 };
1542 apply_methylation_conversion(&mut bases, 5, true, 13, 0, &c, &mut rng);
1543
1544 assert_eq!(&bases, b"CAAAT");
1545 }
1546
1547 #[test]
1548 fn test_apply_methylation_conversion_skips_adapter_bases() {
1549 let cm = single_hap_cm(MethylationTable::empty(10));
1550 // 3 genomic bases + 5 adapter bases. The adapter contains C's that
1551 // must NOT be touched because they have no haplotype position.
1552 let mut bases = b"ACGCCNNN".to_vec();
1553 let mut rng = SmallRng::seed_from_u64(42);
1554
1555 let c = MethylationConfig {
1556 contig_methylation: &cm,
1557 mode: MethylationMode::EmSeq,
1558 conversion_rate: 1.0,
1559 failure_rate: 0.0,
1560 };
1561 apply_methylation_conversion(&mut bases, 3, false, 0, 0, &c, &mut rng);
1562
1563 // bases[0..3] = ACG -> ATG (one C converted)
1564 // bases[3..] untouched -> still CCNNN
1565 assert_eq!(&bases, b"ATGCCNNN");
1566 }
1567
1568 // --- per-molecule conversion-failure tests ---
1569
1570 #[test]
1571 fn test_failed_molecule_retains_all_should_convert_cytosines() {
1572 // failure_rate = 1.0 forces the failed camp; with conversion_rate = 1.0
1573 // the failed camp converts at 1 - 1.0 = 0.0, so every should-convert C
1574 // is retained and the returned flag reports the failure.
1575 let cm = single_hap_cm(MethylationTable::empty(20));
1576 let mut bases = b"ACGTACGT".to_vec();
1577 let mut rng = SmallRng::seed_from_u64(42);
1578
1579 let c = MethylationConfig {
1580 contig_methylation: &cm,
1581 mode: MethylationMode::EmSeq,
1582 conversion_rate: 1.0,
1583 failure_rate: 1.0,
1584 };
1585 let failed = apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
1586
1587 assert!(failed, "molecule should be flagged as a conversion failure");
1588 assert_eq!(&bases, b"ACGTACGT", "failed molecule must retain every should-convert C");
1589 }
1590
1591 #[test]
1592 fn test_failed_molecule_converts_at_one_minus_conversion_rate() {
1593 // conversion_rate = 0.0 means the failed camp converts at 1 - 0.0 = 1.0,
1594 // so a forced-failed molecule converts every should-convert C. Pins the
1595 // "failed rate = 1 - conversion_rate" relationship.
1596 let cm = single_hap_cm(MethylationTable::empty(20));
1597 let mut bases = b"ACGTACGT".to_vec();
1598 let mut rng = SmallRng::seed_from_u64(42);
1599
1600 let c = MethylationConfig {
1601 contig_methylation: &cm,
1602 mode: MethylationMode::EmSeq,
1603 conversion_rate: 0.0,
1604 failure_rate: 1.0,
1605 };
1606 let failed = apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
1607
1608 assert!(failed);
1609 assert_eq!(&bases, b"ATGTATGT", "failed camp at 1 - 0.0 = 1.0 converts every C");
1610 }
1611
1612 #[test]
1613 fn test_failure_rate_zero_never_flags_failure() {
1614 let cm = single_hap_cm(MethylationTable::empty(20));
1615 let mut bases = b"ACGTACGT".to_vec();
1616 let mut rng = SmallRng::seed_from_u64(42);
1617
1618 let c = MethylationConfig {
1619 contig_methylation: &cm,
1620 mode: MethylationMode::EmSeq,
1621 conversion_rate: 1.0,
1622 failure_rate: 0.0,
1623 };
1624 let failed = apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
1625
1626 assert!(!failed, "failure_rate 0.0 must never flag a failure");
1627 assert_eq!(&bases, b"ATGTATGT", "non-failed molecule at rate 1.0 converts every C");
1628 }
1629
1630 #[test]
1631 fn test_zero_genomic_bases_is_noop_but_still_draws_failure() {
1632 // A fully-adapter read (n_genomic = 0) must touch no bases. The
1633 // negative-strand index math `n_genomic - 1 - i` would underflow if
1634 // the per-base loop ran, so this guards that `.take(0)` keeps it out
1635 // of the loop. The per-molecule failure draw still happens (it is
1636 // independent of base count), so the flag is still reported.
1637 let cm = single_hap_cm(MethylationTable::empty(20));
1638 let mut bases = b"CCCCCCCC".to_vec();
1639 let mut rng = SmallRng::seed_from_u64(42);
1640
1641 let c = MethylationConfig {
1642 contig_methylation: &cm,
1643 mode: MethylationMode::EmSeq,
1644 conversion_rate: 1.0,
1645 failure_rate: 1.0,
1646 };
1647 let failed = apply_methylation_conversion(&mut bases, 0, true, 10, 0, &c, &mut rng);
1648
1649 assert!(failed, "failure draw is independent of genomic base count");
1650 assert_eq!(&bases, b"CCCCCCCC", "zero genomic bases must leave the buffer untouched");
1651 }
1652
1653 #[test]
1654 fn test_failure_rate_observed_fraction_matches() {
1655 // Each call models one molecule; ~50% should be flagged failed at
1656 // failure_rate = 0.5. Band derived empirically with
1657 // `SmallRng::seed_from_u64(42)` on rand 0.9; the [0.47, 0.53] window
1658 // is ~4 sigma from 0.5 at n = 5000, so it tolerates RNG-stream churn,
1659 // but may need re-derivation if `SmallRng`'s output stream changes.
1660 let cm = single_hap_cm(MethylationTable::empty(8));
1661 let mut rng = SmallRng::seed_from_u64(42);
1662 let c = MethylationConfig {
1663 contig_methylation: &cm,
1664 mode: MethylationMode::EmSeq,
1665 conversion_rate: 0.999,
1666 failure_rate: 0.5,
1667 };
1668
1669 let n = 5000;
1670 let mut failures = 0;
1671 for _ in 0..n {
1672 let mut bases = b"ACGTACGT".to_vec();
1673 if apply_methylation_conversion(&mut bases, 8, false, 0, 0, &c, &mut rng) {
1674 failures += 1;
1675 }
1676 }
1677 let frac = f64::from(failures) / f64::from(n);
1678 assert!((0.47..=0.53).contains(&frac), "observed failed fraction {frac} out of band");
1679 }
1680
1681 #[test]
1682 fn test_apply_methylation_conversion_em_seq_partial_conversion_rate_empirical() {
1683 let cm = single_hap_cm(MethylationTable::empty(10_000));
1684
1685 // 10_000 C's, conversion rate 0.5, no methylation -> expect ~50% T's.
1686 //
1687 // Band derived empirically with `SmallRng::seed_from_u64(42)` on
1688 // rand 0.9. If `rand` updates `SmallRng`'s output stream, this band
1689 // may need widening or re-derivation.
1690 let mut bases = vec![b'C'; 10_000];
1691 let mut rng = SmallRng::seed_from_u64(42);
1692
1693 let c = MethylationConfig {
1694 contig_methylation: &cm,
1695 mode: MethylationMode::EmSeq,
1696 conversion_rate: 0.5,
1697 failure_rate: 0.0,
1698 };
1699 apply_methylation_conversion(&mut bases, 10_000, false, 0, 0, &c, &mut rng);
1700
1701 #[expect(clippy::naive_bytecount, reason = "test, no bytecount dep")]
1702 let t_count = bases.iter().filter(|&&b| b == b'T').count();
1703 let frac = t_count as f64 / 10_000.0;
1704 assert!((0.48..0.52).contains(&frac), "expected ~50% conversion, got {frac:.3}");
1705 }
1706
1707 #[test]
1708 fn test_apply_methylation_conversion_em_seq_zero_conversion_rate_no_change() {
1709 let cm = single_hap_cm(MethylationTable::empty(10));
1710 let mut bases = b"CCCCCCCC".to_vec();
1711 let mut rng = SmallRng::seed_from_u64(42);
1712
1713 let c = MethylationConfig {
1714 contig_methylation: &cm,
1715 mode: MethylationMode::EmSeq,
1716 conversion_rate: 0.0,
1717 failure_rate: 0.0,
1718 };
1719 apply_methylation_conversion(&mut bases, 8, false, 0, 0, &c, &mut rng);
1720
1721 assert_eq!(&bases, b"CCCCCCCC");
1722 }
1723
1724 #[test]
1725 fn test_apply_methylation_conversion_only_converts_c_not_other_bases() {
1726 let cm = single_hap_cm(MethylationTable::empty(10));
1727 let mut bases = b"AGTNAGTN".to_vec();
1728 let mut rng = SmallRng::seed_from_u64(42);
1729
1730 let c = MethylationConfig {
1731 contig_methylation: &cm,
1732 mode: MethylationMode::EmSeq,
1733 conversion_rate: 1.0,
1734 failure_rate: 0.0,
1735 };
1736 apply_methylation_conversion(&mut bases, 8, false, 0, 0, &c, &mut rng);
1737
1738 assert_eq!(&bases, b"AGTNAGTN");
1739 }
1740
1741 #[test]
1742 fn test_apply_methylation_conversion_with_hap_start_offset() {
1743 // Verify the hap_start offset is applied correctly. Bitmap of length
1744 // 100 with top[50] set; apply conversion to a 10-base read at
1745 // hap_start = 45 with a C at read position 5 → maps to hap_pos 50,
1746 // which is methylated, so it must be preserved at em-seq mode.
1747 let mut table = MethylationTable::empty(100);
1748 table.set_top(50, true);
1749 let cm = single_hap_cm(table);
1750
1751 let mut bases = b"AAAAACAAAA".to_vec();
1752 let mut rng = SmallRng::seed_from_u64(42);
1753
1754 let c = MethylationConfig {
1755 contig_methylation: &cm,
1756 mode: MethylationMode::EmSeq,
1757 conversion_rate: 1.0,
1758 failure_rate: 0.0,
1759 };
1760 apply_methylation_conversion(&mut bases, 10, false, 45, 0, &c, &mut rng);
1761
1762 // The C at read pos 5 → hap pos 50 is methylated → preserved.
1763 assert_eq!(&bases, b"AAAAACAAAA");
1764 }
1765
1766 // --- apply_methylation_conversion tests (TAPS mode) ---
1767
1768 #[test]
1769 fn test_apply_methylation_conversion_taps_methylated_converts() {
1770 // TAPS: methylated cytosines convert. Set top[15]=true so the C at
1771 // read position 5 (covering hap pos 15) is the only one that
1772 // converts.
1773 let mut table = MethylationTable::empty(20);
1774 table.set_top(15, true);
1775 let cm = single_hap_cm(table);
1776
1777 let mut bases = b"ACGTACGT".to_vec();
1778 let mut rng = SmallRng::seed_from_u64(42);
1779
1780 let c = MethylationConfig {
1781 contig_methylation: &cm,
1782 mode: MethylationMode::Taps,
1783 conversion_rate: 1.0,
1784 failure_rate: 0.0,
1785 };
1786 apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
1787
1788 // Read pos 1 -> hap 11: not methylated, preserved.
1789 // Read pos 5 -> hap 15: methylated under TAPS -> converts.
1790 assert_eq!(&bases, b"ACGTATGT");
1791 }
1792
1793 #[test]
1794 fn test_apply_methylation_conversion_taps_unmethylated_preserved() {
1795 // TAPS with no methylation: nothing converts.
1796 let cm = single_hap_cm(MethylationTable::empty(20));
1797 let mut bases = b"ACGTACGT".to_vec();
1798 let mut rng = SmallRng::seed_from_u64(42);
1799
1800 let c = MethylationConfig {
1801 contig_methylation: &cm,
1802 mode: MethylationMode::Taps,
1803 conversion_rate: 1.0,
1804 failure_rate: 0.0,
1805 };
1806 apply_methylation_conversion(&mut bases, 8, false, 10, 0, &c, &mut rng);
1807
1808 assert_eq!(&bases, b"ACGTACGT");
1809 }
1810
1811 #[test]
1812 fn test_apply_methylation_conversion_taps_negative_strand() {
1813 // Methylate hap pos 17 on the bottom strand. With negative-strand
1814 // indexing and hap_start=13, n=5 → bases[0] covers hap pos 17.
1815 // TAPS mode: methylated -> converts. The C at read pos 4 covers
1816 // hap pos 13 (unmethylated under TAPS -> preserved).
1817 let mut table = MethylationTable::empty(20);
1818 table.set_bottom(17, true);
1819 let cm = single_hap_cm(table);
1820
1821 let mut bases = b"CAAAC".to_vec();
1822 let mut rng = SmallRng::seed_from_u64(42);
1823
1824 let c = MethylationConfig {
1825 contig_methylation: &cm,
1826 mode: MethylationMode::Taps,
1827 conversion_rate: 1.0,
1828 failure_rate: 0.0,
1829 };
1830 apply_methylation_conversion(&mut bases, 5, true, 13, 0, &c, &mut rng);
1831
1832 // bases[0] (covers hap 17, methylated bottom) -> T
1833 // bases[4] (covers hap 13, unmethylated) -> stays C
1834 assert_eq!(&bases, b"TAAAC");
1835 }
1836}