Skip to main content

holodeck_lib/commands/
simulate.rs

1use std::fs::File;
2use std::io::BufWriter;
3
4use anyhow::{Result, bail};
5use clap::Parser;
6use pooled_writer::PoolBuilder;
7use pooled_writer::bgzf::BgzfCompressor;
8use rand::rngs::SmallRng;
9use rand::{Rng, SeedableRng};
10use rand_distr::{Distribution, Normal};
11
12use super::command::{Command, output_path};
13use super::common::{BedOptions, OutputPrefixOptions, ReferenceOptions, SeedOptions, VcfOptions};
14use crate::bed::{PaddedIntervalSampler, TargetRegions};
15use crate::error_model::illumina::IlluminaErrorModel;
16use crate::fasta::Fasta;
17use crate::fragment::extract_fragment;
18use crate::haplotype::build_haplotypes;
19use crate::output::fastq::FastqWriter;
20use crate::output::golden_bam::{GoldenBamMetadata, GoldenBamWriter};
21use crate::read::generate_read_pair;
22use crate::seed::{derive_seed, resolve_seed};
23use crate::sequence_dict::SequenceDictionary;
24use crate::version::VERSION;
25
26/// Default sample name used in the golden BAM `@RG SM` field when the
27/// simulation is not driven by a VCF sample.
28const DEFAULT_SAMPLE_NAME: &str = "holodeck-simulation";
29
30/// Default Illumina TruSeq adapter sequence for read 1.
31const DEFAULT_ADAPTER_R1: &str = "AGATCGGAAGAGCACACGTCTGAACTCCAGTCA";
32
33/// Default Illumina TruSeq adapter sequence for read 2.
34const DEFAULT_ADAPTER_R2: &str = "AGATCGGAAGAGCGTCGTGTAGGGAAAGAGTGT";
35
36/// Default per-cytosine conversion rate for molecules in the converted camp
37/// when `--methylation-mode` is set without `--methylation-conversion-rate`.
38const DEFAULT_CONVERSION_RATE: f64 = 0.999;
39
40/// Default fraction of whole-molecule conversion failures when
41/// `--methylation-mode` is set without `--methylation-failure-rate`.
42const DEFAULT_FAILURE_RATE: f64 = 0.01;
43
44/// Simulate sequencing reads from a reference genome.
45///
46/// Generates paired-end or single-end FASTQ files with optional ground-truth
47/// BAM and VCF outputs for benchmarking alignment and variant calling pipelines.
48/// Variants are applied from an input VCF to construct haplotype sequences, and
49/// reads are sampled with a position-dependent Illumina error model.
50#[derive(Parser, Debug)]
51#[command(after_long_help = "EXAMPLES:\n  \
52    holodeck simulate -r ref.fa -o out --coverage 30\n  \
53    holodeck simulate -r ref.fa -v vars.vcf -o out --coverage 30 --golden-bam\n  \
54    holodeck simulate -r ref.fa -v vars.vcf -b targets.bed -o out --coverage 100")]
55#[allow(clippy::struct_excessive_bools)] // CLI flags are naturally boolean
56pub struct Simulate {
57    #[command(flatten)]
58    pub reference: ReferenceOptions,
59
60    #[command(flatten)]
61    pub vcf: VcfOptions,
62
63    #[command(flatten)]
64    pub bed: BedOptions,
65
66    #[command(flatten)]
67    pub output: OutputPrefixOptions,
68
69    #[command(flatten)]
70    pub seed: SeedOptions,
71
72    /// Mean sequencing coverage depth.
73    #[arg(short = 'c', long, default_value_t = 30.0, value_name = "FLOAT")]
74    pub coverage: f64,
75
76    /// Length of each read in bases.
77    #[arg(short = 'l', long, default_value_t = 150, value_name = "INT")]
78    pub read_length: usize,
79
80    /// Mean insert size (outer distance between read pair ends).
81    #[arg(short = 'd', long, default_value_t = 300, value_name = "INT")]
82    pub fragment_mean: usize,
83
84    /// Standard deviation of the insert size distribution.
85    #[arg(short = 's', long, default_value_t = 50, value_name = "INT")]
86    pub fragment_stddev: usize,
87
88    /// Minimum fragment length in bases; sampled lengths below this are clamped
89    /// up.  Fragments shorter than the read length are padded with adapter
90    /// sequence.  Must be at least 1.
91    #[arg(long, default_value_t = 20, value_name = "INT")]
92    pub min_fragment_length: usize,
93
94    /// Generate single-end reads instead of paired-end.
95    #[arg(long)]
96    pub single_end: bool,
97
98    /// Adapter sequence appended to read 1 when the fragment is shorter than
99    /// the read length.
100    #[arg(long, default_value = DEFAULT_ADAPTER_R1, value_name = "SEQ")]
101    pub adapter_r1: String,
102
103    /// Adapter sequence appended to read 2 when the fragment is shorter than
104    /// the read length.
105    #[arg(long, default_value = DEFAULT_ADAPTER_R2, value_name = "SEQ")]
106    pub adapter_r2: String,
107
108    /// Minimum per-base error rate, applied at the start of reads.
109    #[arg(long, default_value_t = 0.001, value_name = "FLOAT")]
110    pub min_error_rate: f64,
111
112    /// Maximum per-base error rate, applied at the end of reads.
113    #[arg(long, default_value_t = 0.01, value_name = "FLOAT")]
114    pub max_error_rate: f64,
115
116    /// Maximum fraction of a read's bases that may come from ambiguous
117    /// reference positions (IUPAC codes or `N`). Reference bases resolved
118    /// from ambiguity codes are flagged at load time and counted per read;
119    /// read pairs whose R1 or R2 exceeds this fraction are rejected and
120    /// resampled. Setting this to `1.0` disables the filter; setting it to
121    /// `0.0` requires every base to come from an unambiguous reference
122    /// position. Value must be in `[0.0, 1.0]`.
123    #[arg(long, default_value_t = 0.02, value_name = "FLOAT")]
124    pub max_n_frac: f64,
125
126    /// Enable methylation chemistry simulation. `em-seq` (or `bisulfite`)
127    /// converts unmethylated cytosines to thymine and preserves methylated
128    /// ones (matches both bisulfite and em-seq protocols). `taps` is the
129    /// inverse: methylated cytosines convert to thymine while unmethylated
130    /// ones are preserved. When omitted, no methylation chemistry is
131    /// applied. Requires a methylation-annotated VCF (MT/MB FORMAT fields)
132    /// produced by `holodeck methylate`.
133    #[arg(long, value_enum, value_name = "MODE")]
134    pub methylation_mode: Option<crate::meth::MethylationMode>,
135
136    /// Probability that the converting class of cytosines (unmethylated
137    /// for em-seq/bisulfite, methylated for TAPS) is converted to thymine
138    /// in a molecule that converted normally. `1.0` is perfect chemistry;
139    /// real protocols typically achieve `0.99`–`0.999`. Only takes effect
140    /// when `--methylation-mode` is set; passing this flag without a mode is
141    /// rejected. Defaults to `0.999` when `--methylation-mode` is set
142    /// without this flag.
143    #[arg(long, value_name = "FLOAT")]
144    pub methylation_conversion_rate: Option<f64>,
145
146    /// Fraction of molecules that are whole-molecule conversion failures.
147    /// Real bisulfite/EM-seq conversion is bimodal: most molecules convert
148    /// near-completely, a small fraction (fragments that fail to denature or
149    /// re-anneal too fast) escape conversion as a coherent unit. A failed
150    /// molecule converts its should-convert cytosines at
151    /// `1 - methylation-conversion-rate` (near-zero), so it retains almost
152    /// all of them as C across both mates. The golden BAM stamps each
153    /// record with `cf:i:{0|1}` recording which camp the molecule was in.
154    /// Only takes effect when `--methylation-mode` is set; passing this flag
155    /// without a mode is rejected. Defaults to `0.01` (1%) when
156    /// `--methylation-mode` is set without this flag; pass `0.0` to disable.
157    #[arg(long, value_name = "FLOAT")]
158    pub methylation_failure_rate: Option<f64>,
159
160    /// Write a per-CpG ground-truth methylation tally in MethylDackel's
161    /// `extract` CpG bedGraph format. Columns: `chrom  start  end  rate
162    /// (0–100)  n_methylated  n_unmethylated`. For every reference CpG
163    /// covered by at least one simulated read, counts how many reads called
164    /// the site as methylated vs unmethylated according to the simulator's
165    /// per-haplotype, per-strand methylation bitmap (NOT the post-conversion
166    /// sequenced base). Lets downstream evaluators measure aligner-derived
167    /// methylation calls against ground truth using the same code paths
168    /// that consume MethylDackel output. Requires `--methylation-mode`.
169    #[arg(long, value_name = "PATH")]
170    pub cpg_truth_bedgraph: Option<std::path::PathBuf>,
171
172    /// Write a ground-truth BAM file with correct alignments.
173    #[arg(long)]
174    pub golden_bam: bool,
175
176    /// Write a ground-truth VCF annotated with simulated coverage.
177    #[arg(long)]
178    pub golden_vcf: bool,
179
180    /// Use simple read names (`holodeck::N`) instead of encoding truth
181    /// coordinates in the read name.
182    #[arg(long)]
183    pub simple_names: bool,
184
185    /// BGZF compression level (0-12). Lower values are faster with larger
186    /// output files; higher values produce smaller files at the cost of speed.
187    /// Level 0 is no compression; 1 is fastest; 12 is maximum compression.
188    #[arg(long, default_value_t = 1, value_name = "INT")]
189    pub compression: u8,
190
191    /// Number of threads for parallel BGZF output compression.
192    #[arg(short = 't', long, default_value_t = 4, value_name = "INT")]
193    pub threads: usize,
194}
195
196impl Command for Simulate {
197    fn execute(&self) -> Result<()> {
198        let resolved_sample = self.validate()?;
199        self.run_simulation(resolved_sample.as_deref())
200    }
201}
202
203impl Simulate {
204    /// Validate command-line arguments before running and return the
205    /// resolved VCF sample name (if a VCF was provided).  Resolving the
206    /// sample here means the VCF header is read only once per run.
207    fn validate(&self) -> Result<Option<String>> {
208        if !self.coverage.is_finite() || self.coverage <= 0.0 {
209            bail!("--coverage must be a finite positive number");
210        }
211        if self.read_length == 0 {
212            bail!("--read-length must be > 0");
213        }
214        if self.min_fragment_length == 0 {
215            bail!("--min-fragment-length must be at least 1");
216        }
217        if self.min_error_rate < 0.0 || self.max_error_rate < 0.0 {
218            bail!("Error rates must be >= 0");
219        }
220        if self.min_error_rate > self.max_error_rate {
221            bail!("--min-error-rate must be <= --max-error-rate");
222        }
223        if !self.max_n_frac.is_finite() || !(0.0..=1.0).contains(&self.max_n_frac) {
224            bail!("--max-n-frac must be in [0.0, 1.0]");
225        }
226        if let Some(rate) = self.methylation_conversion_rate
227            && (!rate.is_finite() || !(0.0..=1.0).contains(&rate))
228        {
229            bail!("--methylation-conversion-rate must be in [0.0, 1.0]");
230        }
231        // Reject orphaned methylation conversion-rate flag: passing a tuning
232        // rate without enabling a chemistry mode is almost certainly a user
233        // mistake, and silently dropping the request is worse than bailing.
234        if self.methylation_mode.is_none() && self.methylation_conversion_rate.is_some() {
235            bail!("--methylation-conversion-rate requires --methylation-mode");
236        }
237        if let Some(rate) = self.methylation_failure_rate
238            && (!rate.is_finite() || !(0.0..=1.0).contains(&rate))
239        {
240            bail!("--methylation-failure-rate must be in [0.0, 1.0]");
241        }
242        if self.methylation_mode.is_none() && self.methylation_failure_rate.is_some() {
243            bail!("--methylation-failure-rate requires --methylation-mode");
244        }
245        if self.cpg_truth_bedgraph.is_some() && self.methylation_mode.is_none() {
246            bail!("--cpg-truth-bedgraph requires --methylation-mode");
247        }
248
249        // Validate `--cpg-truth-bedgraph` parent directory exists up front so
250        // a typo'd path doesn't waste an entire simulation run before
251        // `write_bedgraph` discovers it at the very end.
252        if let Some(path) = &self.cpg_truth_bedgraph
253            && let Some(parent) = path.parent()
254            && !parent.as_os_str().is_empty()
255            && !parent.exists()
256        {
257            bail!("CpG truth bedGraph directory does not exist: {}", parent.display());
258        }
259
260        // Validation matrix for methylation chemistry simulation:
261        // - --methylation-mode requires the input VCF to actually carry MT/MB
262        //   methylation records (run `holodeck methylate` first to produce one).
263        // - A methylated VCF without --methylation-mode produces a warning and
264        //   variants-only output (user almost certainly forgot the mode flag).
265        //
266        // Probe for real records, not just header declarations: a VCF that
267        // declares MT/MB in the header but has no annotated records carries no
268        // methylation truth, and accepting it here would defer the failure to
269        // an internal-invariant panic deep in the per-contig loop.
270        let vcf_has_methylation = if let Some(vcf_path) = &self.vcf.vcf {
271            crate::vcf::methylation::vcf_has_mt_mb_records(vcf_path)
272                .map_err(|e| anyhow::anyhow!("failed to inspect VCF for MT/MB records: {e}"))?
273        } else {
274            false
275        };
276        match (vcf_has_methylation, self.methylation_mode.is_some()) {
277            (true, true) | (false, false) => {} // normal paths
278            (true, false) => {
279                log::warn!(
280                    "VCF contains methylation truth (MT/MB) but --methylation-mode is not set; \
281                     methylation chemistry will not be applied"
282                );
283            }
284            (false, true) => {
285                bail!(
286                    "--methylation-mode requires a methylation-annotated VCF (MT/MB FORMAT \
287                     fields); run `holodeck methylate` first"
288                );
289            }
290        }
291        if self.compression > 12 {
292            bail!("--compression must be between 0 and 12");
293        }
294
295        // --sample without --vcf is nonsensical.
296        if self.vcf.sample.is_some() && self.vcf.vcf.is_none() {
297            bail!("--sample requires --vcf");
298        }
299
300        // Validate VCF sample configuration upfront so the user gets a clear
301        // error before the simulation loop starts, and capture the resolved
302        // sample name for use in downstream metadata (e.g. the golden BAM
303        // `@RG` line).
304        let resolved_sample = if let Some(vcf_path) = &self.vcf.vcf {
305            Some(crate::vcf::validate_vcf_sample(vcf_path, self.vcf.sample.as_deref())?)
306        } else {
307            None
308        };
309
310        // Validate output parent directory exists.
311        if let Some(parent) = self.output.output.parent()
312            && !parent.as_os_str().is_empty()
313            && !parent.exists()
314        {
315            bail!("Output directory does not exist: {}", parent.display());
316        }
317
318        Ok(resolved_sample)
319    }
320
321    /// Run the main simulation pipeline.
322    ///
323    /// `resolved_vcf_sample` is the sample name resolved from the VCF during
324    /// validation (if any), used for the golden BAM `@RG SM` field.
325    #[allow(clippy::too_many_lines)] // Top-level orchestrator.
326    fn run_simulation(&self, resolved_vcf_sample: Option<&str>) -> Result<()> {
327        let seed = self.compute_seed();
328        let mut rng = SmallRng::seed_from_u64(seed);
329        log::info!("Using random seed: {seed}");
330
331        let mut fasta = Fasta::from_path(&self.reference.reference)?;
332        let dict = fasta.dict().clone();
333        log::info!(
334            "Loaded reference with {} contigs, total {} bp",
335            dict.len(),
336            dict.total_length()
337        );
338
339        let targets = self.load_targets(&dict)?;
340        let effective_size = targets
341            .as_ref()
342            .map_or(dict.total_length(), |t| t.effective_territory(self.fragment_mean));
343        if effective_size == 0 {
344            bail!("Effective genome size is 0; nothing to simulate");
345        }
346
347        let total_reads = self.compute_total_reads(effective_size);
348        log::info!("Will generate {total_reads} read pairs for {:.1}x coverage", self.coverage);
349
350        let error_model =
351            IlluminaErrorModel::new(self.read_length, self.min_error_rate, self.max_error_rate);
352        let frag_dist = Normal::new(self.fragment_mean as f64, self.fragment_stddev as f64)
353            .map_err(|e| anyhow::anyhow!("Invalid fragment distribution parameters: {e}"))?;
354
355        // Normalize user-supplied adapter sequences to uppercase so they
356        // don't register as ambiguity-resolved bases (lowercase marker) in
357        // the per-read lowercase-fraction filter.
358        let adapter_r1 = self.adapter_r1.to_ascii_uppercase();
359        let adapter_r2 = self.adapter_r2.to_ascii_uppercase();
360
361        let compression = self.compression;
362        let use_pool = self.threads > 1;
363
364        // When using multiple threads, create a shared compression pool that
365        // handles BGZF block compression and writing across all output files.
366        let mut pool_builder: Option<PoolBuilder<BufWriter<File>, BgzfCompressor>> = if use_pool {
367            let pb = PoolBuilder::new()
368                .threads(self.threads)
369                .compression_level(compression)
370                .map_err(|e| anyhow::anyhow!("failed to set compression level: {e}"))?;
371            log::info!("Using {} threads for BGZF compression", self.threads);
372            Some(pb)
373        } else {
374            None
375        };
376
377        let mut r1_writer = self.create_fastq_writer(".r1.fastq.gz", &mut pool_builder)?;
378        let mut r2_writer = if self.single_end {
379            None
380        } else {
381            Some(self.create_fastq_writer(".r2.fastq.gz", &mut pool_builder)?)
382        };
383
384        let mut golden_bam_writer = if self.golden_bam {
385            let bam_path = output_path(&self.output.output, ".golden.bam");
386            log::info!("Writing golden BAM to: {}", bam_path.display());
387            let meta = Self::golden_bam_metadata(resolved_vcf_sample);
388            if let Some(pb) = &mut pool_builder {
389                let file = File::create(&bam_path)?;
390                let pooled = pb.exchange(BufWriter::new(file));
391                Some(GoldenBamWriter::from_writer(Box::new(pooled), &dict, &meta)?)
392            } else {
393                Some(GoldenBamWriter::new(&bam_path, &dict, compression, &meta)?)
394            }
395        } else {
396            None
397        };
398
399        // Build the pool after all writers have been exchanged.
400        let mut pool = pool_builder
401            .map(PoolBuilder::build)
402            .transpose()
403            .map_err(|e| anyhow::anyhow!("failed to build compression pool: {e}"))?;
404
405        if self.golden_vcf {
406            log::warn!("--golden-vcf is not yet implemented; skipping");
407        }
408
409        // CpG truth tally is built across all contigs and written once at the
410        // end. Validation in `validate()` ensures `--cpg-truth-bedgraph` is
411        // only set when methylation chemistry is also enabled.
412        let mut cpg_truth = self
413            .cpg_truth_bedgraph
414            .as_ref()
415            .map(|_| crate::output::cpg_truth::CpgTruthTally::new());
416
417        let mut read_num: u64 = 0;
418        let contig_names: Vec<String> = dict.names().into_iter().map(String::from).collect();
419
420        // Parse the methylation-annotated VCF (if any) once up front so the
421        // per-contig loop can pluck out per-contig records without
422        // re-decompressing or re-scanning the file every iteration.
423        // Validation in `validate()` ensures that `--methylation-mode` is only
424        // accepted when the VCF actually declares MT/MB.
425        let methylation_records = if self.methylation_mode.is_some()
426            && let Some(vcf_path) = &self.vcf.vcf
427        {
428            Some(crate::vcf::methylation::parse_methylation_vcf(vcf_path)?)
429        } else {
430            None
431        };
432
433        // Parse variants once, partitioning by contig. The per-contig loop
434        // below looks up its slice via `HashMap::get` instead of re-scanning
435        // the full VCF on every contig (the latter is O(contigs × records)
436        // and dominates wall time on whole-genome inputs). The scan also
437        // resolves the sample's ploidy from unfiltered GTs, so variant-free
438        // contigs are sized the same as variant-bearing ones — a per-contig
439        // fallback to `2` would silently mis-shape MT/MB on haploid /
440        // triploid samples.
441        let parsed_variants = if let Some(vcf_path) = &self.vcf.vcf {
442            Some(crate::vcf::parse_variants_by_contig(vcf_path, resolved_vcf_sample, &dict)?)
443        } else {
444            None
445        };
446        let sample_ploidy = parsed_variants.as_ref().map_or(2, |p| p.sample_ploidy);
447
448        for contig_name in &contig_names {
449            let variants_for_contig: &[crate::vcf::genotype::VariantRecord] = parsed_variants
450                .as_ref()
451                .and_then(|p| p.by_contig.get(contig_name))
452                .map_or(&[], Vec::as_slice);
453            read_num += self.simulate_contig(
454                contig_name,
455                &dict,
456                &mut fasta,
457                targets.as_ref(),
458                total_reads,
459                &error_model,
460                &frag_dist,
461                adapter_r1.as_bytes(),
462                adapter_r2.as_bytes(),
463                &mut r1_writer,
464                &mut r2_writer,
465                &mut golden_bam_writer,
466                variants_for_contig,
467                sample_ploidy,
468                cpg_truth.as_mut(),
469                methylation_records.as_ref(),
470                read_num,
471                seed,
472                &mut rng,
473            )?;
474        }
475
476        // Close writers first so pooled writers flush their buffers to the
477        // pool, then stop the pool to wait for all compression/writing to
478        // complete.
479        r1_writer.close();
480        if let Some(w) = r2_writer {
481            w.close();
482        }
483        if let Some(w) = golden_bam_writer {
484            w.close();
485        }
486        if let Some(ref mut p) = pool {
487            p.stop_pool().map_err(|e| anyhow::anyhow!("failed to stop compression pool: {e}"))?;
488        }
489
490        if let (Some(path), Some(tally)) = (&self.cpg_truth_bedgraph, &cpg_truth) {
491            log::info!("Writing CpG truth bedGraph to: {}", path.display());
492            tally.write_bedgraph(&dict, path)?;
493        }
494
495        log::info!("Generated {read_num} total read pairs");
496        Ok(())
497    }
498
499    /// Create a FASTQ writer, using the compression pool if available or
500    /// single-threaded BGZF otherwise.
501    fn create_fastq_writer(
502        &self,
503        suffix: &str,
504        pool_builder: &mut Option<PoolBuilder<BufWriter<File>, BgzfCompressor>>,
505    ) -> Result<FastqWriter> {
506        let path = output_path(&self.output.output, suffix);
507        if let Some(pb) = pool_builder {
508            let file = File::create(&path)?;
509            let pooled = pb.exchange(BufWriter::new(file));
510            Ok(FastqWriter::from_writer(pooled))
511        } else {
512            FastqWriter::new(&path, self.compression)
513        }
514    }
515
516    /// Build the `@PG`/`@RG` metadata for the golden BAM header.  The
517    /// command line is captured verbatim from `std::env::args_os`, using
518    /// lossy UTF-8 conversion so that non-Unicode arguments do not panic.
519    /// `resolved_vcf_sample` should be the sample name returned by
520    /// [`crate::vcf::validate_vcf_sample`] during validation; when absent,
521    /// the sample defaults to [`DEFAULT_SAMPLE_NAME`].
522    fn golden_bam_metadata(resolved_vcf_sample: Option<&str>) -> GoldenBamMetadata {
523        let command_line = std::env::args_os()
524            .map(|arg| arg.to_string_lossy().into_owned())
525            .collect::<Vec<_>>()
526            .join(" ");
527        let sample =
528            resolved_vcf_sample.map_or_else(|| DEFAULT_SAMPLE_NAME.to_string(), str::to_string);
529        GoldenBamMetadata { command_line, version: VERSION.clone(), sample }
530    }
531
532    /// Compute the deterministic seed from simulation parameters.
533    ///
534    /// IMPORTANT: the legacy 7-field format
535    /// `<reference>:<coverage>:<read_length>:<frag_mean>:<frag_stddev>:<min_err>:<max_err>`
536    /// MUST NOT change. Existing users rely on the same params producing the
537    /// same seed. New parameters (e.g. methylation chemistry) must only
538    /// extend the string when explicitly enabled, and only as conditional
539    /// suffixes.
540    fn compute_seed(&self) -> u64 {
541        let mut seed_desc = format!(
542            "{}:{}:{}:{}:{}:{}:{}",
543            self.reference.reference.display(),
544            self.coverage,
545            self.read_length,
546            self.fragment_mean,
547            self.fragment_stddev,
548            self.min_error_rate,
549            self.max_error_rate,
550        );
551        if let Some(mode) = self.methylation_mode {
552            seed_desc.push(':');
553            seed_desc.push_str(mode.as_seed_str());
554            seed_desc.push(':');
555            seed_desc.push_str(
556                &self.methylation_conversion_rate.unwrap_or(DEFAULT_CONVERSION_RATE).to_string(),
557            );
558            seed_desc.push(':');
559            seed_desc.push_str(
560                &self.methylation_failure_rate.unwrap_or(DEFAULT_FAILURE_RATE).to_string(),
561            );
562        }
563        resolve_seed(self.seed.seed, &seed_desc)
564    }
565
566    /// Load BED target regions if specified.
567    fn load_targets(&self, dict: &SequenceDictionary) -> Result<Option<TargetRegions>> {
568        match &self.bed.targets {
569            Some(bed_path) => {
570                let t = TargetRegions::from_path(bed_path, dict)?;
571                log::info!("Loaded {} bp of target territory", t.total_territory());
572                Ok(Some(t))
573            }
574            None => Ok(None),
575        }
576    }
577
578    /// Compute total number of read pairs needed for the requested coverage.
579    fn compute_total_reads(&self, effective_size: u64) -> u64 {
580        let bases_per_read =
581            if self.single_end { self.read_length as u64 } else { self.read_length as u64 * 2 };
582        #[expect(clippy::cast_possible_truncation, reason = "read count fits u64")]
583        #[expect(clippy::cast_sign_loss, reason = "coverage is positive")]
584        let n = ((self.coverage * effective_size as f64) / bases_per_read as f64).round() as u64;
585        n
586    }
587
588    /// Simulate reads for a single contig.
589    ///
590    /// `variants` is this contig's slice of the once-parsed
591    /// `variants_by_contig` map (empty when no VCF is provided, or when the
592    /// VCF has no variants on this contig).
593    #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
594    fn simulate_contig(
595        &self,
596        contig_name: &str,
597        dict: &SequenceDictionary,
598        fasta: &mut Fasta,
599        targets: Option<&TargetRegions>,
600        total_reads: u64,
601        error_model: &IlluminaErrorModel,
602        frag_dist: &Normal<f64>,
603        adapter_r1: &[u8],
604        adapter_r2: &[u8],
605        r1_writer: &mut FastqWriter,
606        r2_writer: &mut Option<FastqWriter>,
607        golden_bam: &mut Option<GoldenBamWriter>,
608        variants: &[crate::vcf::genotype::VariantRecord],
609        sample_ploidy: usize,
610        cpg_truth: Option<&mut crate::output::cpg_truth::CpgTruthTally>,
611        methylation_records: Option<&crate::vcf::methylation::MethylationVcfRecords>,
612        start_read_num: u64,
613        main_seed: u64,
614        rng: &mut SmallRng,
615    ) -> Result<u64> {
616        let contig_meta = dict.get_by_name(contig_name).unwrap();
617        let contig_len = contig_meta.length() as u64;
618        let contig_idx = contig_meta.index();
619
620        // Compute reads proportional to effective territory (if BED) or contig
621        // size (whole genome).  For targeted mode, effective territory accounts
622        // for the fact that fragments extend beyond targets — see
623        // TargetRegions::effective_territory for the derivation.
624        let contig_effective_size = targets
625            .map_or(contig_len, |t| t.contig_effective_territory(contig_idx, self.fragment_mean));
626        let effective_total =
627            targets.map_or(dict.total_length(), |t| t.effective_territory(self.fragment_mean));
628
629        if contig_effective_size == 0 || effective_total == 0 {
630            return Ok(0);
631        }
632
633        #[expect(clippy::cast_possible_truncation, reason = "read count fits u64")]
634        #[expect(clippy::cast_sign_loss, reason = "fraction is positive")]
635        let contig_reads = (total_reads as f64 * contig_effective_size as f64
636            / effective_total as f64)
637            .round() as u64;
638
639        if contig_reads == 0 {
640            return Ok(0);
641        }
642
643        log::info!("Simulating {contig_reads} reads for contig {contig_name} ({contig_len} bp)");
644
645        // Build a padded interval sampler when targets exist.  The pad covers
646        // the catchment zone — fragment start positions outside a target whose
647        // fragment still extends into the target.  Fragments whose drawn length
648        // is too short to actually reach a target are caught by the overlap
649        // check below (rare with this padding).
650        #[expect(clippy::cast_possible_truncation, reason = "pad fits u32")]
651        let sampler = targets.map(|tgt| {
652            let pad = (self.fragment_mean + 4 * self.fragment_stddev) as u32;
653            PaddedIntervalSampler::new(tgt.contig_intervals(contig_idx), pad, contig_len as u32)
654        });
655
656        // Seed a dedicated RNG for reference normalization so ambiguity-code
657        // resolution is reproducible and independent of the main sampling RNG.
658        let contig_seed = derive_seed(main_seed, contig_name);
659        let mut ref_rng = SmallRng::seed_from_u64(contig_seed);
660        let reference = fasta.load_contig(contig_name, &mut ref_rng)?;
661
662        // Variants were partitioned by contig once in `run_simulation` —
663        // here we just log how many landed on this contig.
664        if !variants.is_empty() {
665            log::info!("  Loaded {} variants for {contig_name}", variants.len());
666        }
667
668        // Size the haplotype set by `sample_ploidy` (resolved once over the
669        // whole VCF by `run_simulation`), not by a per-contig `max + 2`
670        // fallback. This keeps variant-free contigs aligned with the rest
671        // of the sample on haploid / triploid genomes.
672        let haplotypes = build_haplotypes(variants, sample_ploidy, rng);
673
674        // Build per-haplotype methylation tables AFTER haplotypes exist so
675        // that variant-driven CpG creations/destructions are reflected on
676        // each haplotype's bitmap. Methylation truth must come from a VCF
677        // produced by `holodeck methylate` (MT/MB FORMAT fields). The
678        // validation matrix in `validate()` ensures that when
679        // `--methylation-mode` is set, the VCF always declares MT/MB and
680        // `methylation_records` is `Some(...)`; the `ok_or_else` below is
681        // a defensive guard for that invariant.
682        let methylation = if let Some(mode) = self.methylation_mode {
683            let records = methylation_records.ok_or_else(|| {
684                anyhow::anyhow!(
685                    "internal invariant: --methylation-mode set but methylation \
686                     records were not loaded (should be caught by validate())"
687                )
688            })?;
689            let cm = crate::vcf::methylation::load_contig_methylation_from_records(
690                records,
691                contig_name,
692                &reference,
693                variants,
694                sample_ploidy,
695            )?
696            .ok_or_else(|| {
697                anyhow::anyhow!(
698                    "internal invariant: --methylation-mode set but VCF lacks MT/MB \
699                     (should be caught by validate())"
700                )
701            })?;
702            // The methylation reader derives its ploidy from `variants` the
703            // same way we do above, so the two haplotype counts must agree.
704            // Surface any drift loudly at contig-load time instead of as a
705            // per-read `cm.table_for(hap_idx)` panic deep in the hot loop.
706            anyhow::ensure!(
707                cm.len() == haplotypes.len(),
708                "internal invariant: methylation table count ({}) disagrees with \
709                 haplotype count ({}) on contig {contig_name}",
710                cm.len(),
711                haplotypes.len(),
712            );
713            Some((cm, mode))
714        } else {
715            None
716        };
717        let methylation_config =
718            methylation.as_ref().map(|(cm, mode)| crate::meth::MethylationConfig {
719                contig_methylation: cm,
720                mode: *mode,
721                conversion_rate: self
722                    .methylation_conversion_rate
723                    .unwrap_or(DEFAULT_CONVERSION_RATE),
724                failure_rate: self.methylation_failure_rate.unwrap_or(DEFAULT_FAILURE_RATE),
725            });
726
727        // Precompute reference CpG positions once per contig (only when the
728        // CpG truth bedGraph is requested — otherwise we'd waste a scan).
729        // The list is reused for every fragment's per-mate tally.
730        let ref_cpgs: Vec<u32> = if cpg_truth.is_some() {
731            crate::meth::find_reference_cpgs(&reference)
732        } else {
733            Vec::new()
734        };
735        let mut cpg_truth = cpg_truth;
736
737        let mut generated: u64 = 0;
738        let mut attempts: u64 = 0;
739        let max_attempts = contig_reads * 100;
740
741        while generated < contig_reads && attempts < max_attempts {
742            attempts += 1;
743
744            // Draw fragment length, clamped to [min_fragment_length, contig_length].
745            // Fragments shorter than read_length are padded with adapter sequence
746            // by the read extraction layer.
747            #[expect(clippy::cast_possible_truncation, reason = "fragment length fits usize")]
748            #[expect(clippy::cast_sign_loss, reason = "clamped to positive")]
749            let frag_len = frag_dist
750                .sample(rng)
751                .round()
752                .clamp(self.min_fragment_length as f64, contig_len as f64)
753                as usize;
754
755            if frag_len == 0 {
756                continue;
757            }
758
759            // Pick a random start position.  When targets exist, sample from
760            // the padded target regions so that nearly every draw overlaps a
761            // target — vastly more efficient than rejection-sampling across
762            // the whole contig.
763            #[expect(clippy::cast_possible_truncation, reason = "position fits u32")]
764            let ref_start = if let Some(samp) = &sampler {
765                let s = samp.sample_start(rng).unwrap();
766                // Ensure the fragment fits within the contig.
767                s.min((contig_len - frag_len as u64) as u32)
768            } else {
769                let max_start = contig_len - frag_len as u64;
770                if max_start > 0 { rng.random_range(0..=max_start) as u32 } else { 0 }
771            };
772
773            // Check BED target overlap — with padded sampling this rarely
774            // rejects, but catches the occasional short fragment drawn from
775            // the pad zone that doesn't reach the target.
776            #[expect(clippy::cast_possible_truncation, reason = "frag end fits u32")]
777            let frag_end = ref_start + frag_len as u32;
778            if let Some(tgt) = targets
779                && !tgt.overlaps(contig_idx, ref_start, frag_end)
780            {
781                continue;
782            }
783
784            let hap_idx = rng.random_range(0..haplotypes.len());
785            let is_forward: bool = rng.random();
786            let fragment =
787                extract_fragment(&haplotypes[hap_idx], &reference, ref_start, frag_len, is_forward);
788
789            let read_num = start_read_num + generated + 1;
790            let Some(mut pair) = generate_read_pair(
791                &fragment,
792                contig_name,
793                read_num,
794                self.read_length,
795                !self.single_end,
796                adapter_r1,
797                adapter_r2,
798                self.max_n_frac,
799                error_model,
800                self.simple_names,
801                methylation_config.as_ref(),
802                // Pre-conversion bases are only consumed by the golden BAM's
803                // YS:Z tag — skip the per-mate clone when no golden BAM is
804                // requested.
805                self.golden_bam,
806                rng,
807            ) else {
808                // Too many ambiguity-resolved bases in this read pair; resample.
809                continue;
810            };
811
812            // When both methylation and a golden BAM are requested, compute
813            // the per-record Bismark methylation call tags (XM/YM/NM/MD)
814            // before writing. This is the single place where the reference,
815            // haplotype, and methylation table are all in scope.
816            if self.golden_bam
817                && let (Some(annotation), Some((cm, mode))) =
818                    (pair.methylation.as_mut(), methylation.as_ref())
819            {
820                crate::methylation_tags::populate_pair_call_tags(
821                    &pair.read1,
822                    pair.read2.as_ref(),
823                    &pair.r1_truth,
824                    pair.r2_truth.as_ref(),
825                    &pair.r1_cigar,
826                    pair.r2_cigar.as_ref(),
827                    &reference,
828                    cm.table_for(hap_idx),
829                    &haplotypes[hap_idx],
830                    *mode,
831                    annotation,
832                );
833            }
834
835            r1_writer.write_read(&pair.read1)?;
836            if let Some(w) = r2_writer
837                && let Some(r2) = &pair.read2
838            {
839                w.write_read(r2)?;
840            }
841            if let Some(bam_w) = golden_bam {
842                bam_w.write_pair(&pair)?;
843            }
844
845            // Tally CpG truth from each mate's genomic ref-position slice.
846            // Mirrors `read::build_mate`'s slicing: forward mates take the
847            // left window (`[..genomic]`); negative-strand mates take the
848            // right window (`[frag_len - genomic..frag_len]`).
849            if let (Some(tally), Some((cm, _))) = (cpg_truth.as_deref_mut(), methylation.as_ref()) {
850                let frag_len = fragment.bases.len();
851                let genomic = frag_len.min(self.read_length);
852                let right_start = frag_len.saturating_sub(genomic);
853                let r1_neg = !fragment.is_forward;
854                let r1_positions: &[u32] = if r1_neg {
855                    &fragment.ref_positions[right_start..frag_len]
856                } else {
857                    &fragment.ref_positions[..genomic]
858                };
859                tally.record_mate(
860                    contig_idx,
861                    r1_positions,
862                    &ref_cpgs,
863                    cm,
864                    &haplotypes[hap_idx],
865                    hap_idx,
866                    fragment.is_forward,
867                );
868                if pair.read2.is_some() {
869                    let r2_neg = fragment.is_forward;
870                    let r2_positions: &[u32] = if r2_neg {
871                        &fragment.ref_positions[right_start..frag_len]
872                    } else {
873                        &fragment.ref_positions[..genomic]
874                    };
875                    tally.record_mate(
876                        contig_idx,
877                        r2_positions,
878                        &ref_cpgs,
879                        cm,
880                        &haplotypes[hap_idx],
881                        hap_idx,
882                        fragment.is_forward,
883                    );
884                }
885            }
886
887            generated += 1;
888        }
889
890        if generated < contig_reads {
891            log::warn!(
892                "Only generated {generated}/{contig_reads} reads for {contig_name} \
893                 after {max_attempts} attempts"
894            );
895        }
896
897        Ok(generated)
898    }
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904    use crate::commands::common::{
905        BedOptions, OutputPrefixOptions, ReferenceOptions, SeedOptions, VcfOptions,
906    };
907    use std::path::PathBuf;
908
909    /// Build a `Simulate` populated with the same defaults the CLI uses, so
910    /// tests construct realistic instances without going through clap.
911    fn make_default_simulate() -> Simulate {
912        Simulate {
913            reference: ReferenceOptions { reference: PathBuf::from("/tmp/ref.fa") },
914            vcf: VcfOptions { vcf: None, sample: None },
915            bed: BedOptions { targets: None },
916            output: OutputPrefixOptions { output: PathBuf::from("out") },
917            seed: SeedOptions { seed: None },
918            coverage: 30.0,
919            read_length: 150,
920            fragment_mean: 300,
921            fragment_stddev: 50,
922            min_fragment_length: 20,
923            single_end: false,
924            adapter_r1: DEFAULT_ADAPTER_R1.to_string(),
925            adapter_r2: DEFAULT_ADAPTER_R2.to_string(),
926            min_error_rate: 0.001,
927            max_error_rate: 0.01,
928            max_n_frac: 0.02,
929            methylation_mode: None,
930            methylation_conversion_rate: None,
931            methylation_failure_rate: None,
932            cpg_truth_bedgraph: None,
933            golden_bam: false,
934            golden_vcf: false,
935            simple_names: false,
936            compression: 1,
937            threads: 4,
938        }
939    }
940
941    /// Non-methylation invocations must produce the same seed they did
942    /// before the methylation fields were added — i.e. the seed string is
943    /// exactly
944    /// `"<ref>:<coverage>:<read_length>:<fragment_mean>:<fragment_stddev>:<min_err>:<max_err>"`
945    /// with no trailing fields. This pins the legacy 7-field format.
946    #[test]
947    fn test_compute_seed_no_methylation_matches_legacy() {
948        let sim = make_default_simulate();
949        let expected = resolve_seed(None, "/tmp/ref.fa:30:150:300:50:0.001:0.01");
950        assert_eq!(sim.compute_seed(), expected);
951    }
952
953    /// Enabling methylation simulation extends the seed string with the
954    /// three extra fields, which yields a different seed than the
955    /// non-methylation case.
956    #[test]
957    fn test_compute_seed_methylation_differs_from_legacy() {
958        let mut sim = make_default_simulate();
959        let baseline = sim.compute_seed();
960        sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
961        assert_ne!(sim.compute_seed(), baseline);
962    }
963
964    /// An explicit `--seed` short-circuits the description-derived seed,
965    /// so toggling methylation must not change the resolved value.
966    #[test]
967    fn test_compute_seed_explicit_seed_unaffected_by_methylation() {
968        let mut sim = make_default_simulate();
969        sim.seed.seed = Some(42);
970        assert_eq!(sim.compute_seed(), 42);
971        sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
972        assert_eq!(sim.compute_seed(), 42);
973    }
974
975    /// Em-seq and TAPS modes feed different strings into the seed
976    /// derivation, so they must produce different seeds with otherwise
977    /// identical parameters.
978    #[test]
979    fn test_compute_seed_em_seq_and_taps_differ() {
980        let mut sim_em = make_default_simulate();
981        sim_em.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
982        let mut sim_taps = make_default_simulate();
983        sim_taps.methylation_mode = Some(crate::meth::MethylationMode::Taps);
984        assert_ne!(sim_em.compute_seed(), sim_taps.compute_seed());
985    }
986
987    /// `--methylation-failure-rate` feeds the seed string, so two runs that
988    /// differ only in failure rate must derive different seeds (otherwise
989    /// reruns at a new failure rate would reuse the prior stream).
990    #[test]
991    fn test_compute_seed_differs_by_failure_rate() {
992        let mut sim_a = make_default_simulate();
993        sim_a.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
994        sim_a.methylation_failure_rate = Some(0.0);
995        let mut sim_b = make_default_simulate();
996        sim_b.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
997        sim_b.methylation_failure_rate = Some(0.5);
998        assert_ne!(sim_a.compute_seed(), sim_b.compute_seed());
999    }
1000
1001    /// Non-default `--methylation-conversion-rate` without a mode is also
1002    /// rejected.
1003    #[test]
1004    fn test_methylation_conversion_rate_without_mode_rejected() {
1005        let mut sim = make_default_simulate();
1006        sim.methylation_mode = None;
1007        sim.methylation_conversion_rate = Some(0.5);
1008        let err =
1009            sim.validate().expect_err("validate must reject orphaned methylation conversion rate");
1010        let msg = format!("{err}");
1011        assert!(
1012            msg.contains("--methylation-conversion-rate requires --methylation-mode"),
1013            "error must mention required mode flag, got: {msg}"
1014        );
1015    }
1016
1017    /// Default rates with no mode is the normal "no methylation" path and
1018    /// must not be rejected.
1019    #[test]
1020    fn test_default_rates_without_mode_accepted() {
1021        let sim = make_default_simulate();
1022        assert!(sim.validate().is_ok(), "defaults without mode must validate cleanly");
1023    }
1024
1025    /// Explicit `--methylation-conversion-rate 1.0` without a mode is still a
1026    /// user error: the validator detects *flag presence*, not just numeric
1027    /// override.
1028    #[test]
1029    fn test_methylation_conversion_rate_explicit_one_without_mode_rejected() {
1030        let mut sim = make_default_simulate();
1031        sim.methylation_mode = None;
1032        sim.methylation_conversion_rate = Some(1.0);
1033        let err = sim.validate().expect_err(
1034            "validate must reject explicit --methylation-conversion-rate 1.0 without mode",
1035        );
1036        let msg = format!("{err}");
1037        assert!(
1038            msg.contains("--methylation-conversion-rate requires --methylation-mode"),
1039            "error must mention required mode flag, got: {msg}"
1040        );
1041    }
1042
1043    /// `--methylation-failure-rate` without a mode is rejected, mirroring the
1044    /// conversion-rate orphan check.
1045    #[test]
1046    fn test_methylation_failure_rate_without_mode_rejected() {
1047        let mut sim = make_default_simulate();
1048        sim.methylation_mode = None;
1049        sim.methylation_failure_rate = Some(0.05);
1050        let err =
1051            sim.validate().expect_err("validate must reject orphaned methylation failure rate");
1052        let msg = format!("{err}");
1053        assert!(
1054            msg.contains("--methylation-failure-rate requires --methylation-mode"),
1055            "error must mention required mode flag, got: {msg}"
1056        );
1057    }
1058
1059    /// `--methylation-failure-rate` outside `[0, 1]` is rejected even with a
1060    /// mode set.
1061    #[test]
1062    fn test_methylation_failure_rate_out_of_range_rejected() {
1063        let mut sim = make_default_simulate();
1064        sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
1065        sim.methylation_failure_rate = Some(1.5);
1066        let err = sim
1067            .validate()
1068            .expect_err("validate must reject --methylation-failure-rate outside [0, 1]");
1069        let msg = format!("{err}");
1070        assert!(
1071            msg.contains("--methylation-failure-rate must be in [0.0, 1.0]"),
1072            "error must mention the valid range, got: {msg}"
1073        );
1074    }
1075
1076    /// `--methylation-conversion-rate` outside `[0, 1]` must be rejected with
1077    /// the mode set (mirrors the failure-rate guard, which is tested above).
1078    #[test]
1079    fn test_methylation_conversion_rate_out_of_range_rejected() {
1080        let mut sim = make_default_simulate();
1081        sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
1082        sim.methylation_conversion_rate = Some(1.5);
1083        let err = sim
1084            .validate()
1085            .expect_err("validate must reject --methylation-conversion-rate outside [0, 1]");
1086        let msg = format!("{err}");
1087        assert!(
1088            msg.contains("--methylation-conversion-rate must be in [0.0, 1.0]"),
1089            "error must mention the valid range, got: {msg}"
1090        );
1091    }
1092
1093    /// A non-finite (NaN) `--methylation-conversion-rate` must also be rejected
1094    /// by the same range guard — `(0.0..=1.0).contains(&NaN)` is `false`.
1095    #[test]
1096    fn test_methylation_conversion_rate_nan_rejected() {
1097        let mut sim = make_default_simulate();
1098        sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
1099        sim.methylation_conversion_rate = Some(f64::NAN);
1100        let err =
1101            sim.validate().expect_err("validate must reject a NaN --methylation-conversion-rate");
1102        assert!(
1103            format!("{err}").contains("--methylation-conversion-rate must be in [0.0, 1.0]"),
1104            "error must mention the valid range"
1105        );
1106    }
1107
1108    /// A `--cpg-truth-bedgraph` path whose parent directory does not exist
1109    /// must be rejected at validate time, not several minutes into the
1110    /// simulation when `write_bedgraph` tries to create the file. Mirrors
1111    /// the existing fail-fast check on `--output`.
1112    #[test]
1113    fn test_cpg_truth_bedgraph_missing_parent_rejected() {
1114        let mut sim = make_default_simulate();
1115        sim.methylation_mode = Some(crate::meth::MethylationMode::EmSeq);
1116        sim.cpg_truth_bedgraph =
1117            Some(PathBuf::from("/nonexistent-path-for-holodeck-test/sub/cpg.bg"));
1118        let err = sim
1119            .validate()
1120            .expect_err("validate must reject --cpg-truth-bedgraph with a non-existent parent");
1121        let msg = format!("{err}");
1122        assert!(
1123            msg.contains("CpG truth bedGraph directory does not exist"),
1124            "error must mention missing CpG truth bedGraph directory, got: {msg}"
1125        );
1126    }
1127}