Skip to main content

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}