Skip to main content

ciri_toolkit/simulator/
mod.rs

1//! Standalone simulator for CIRI-toolkit full-length reconstruction fixtures.
2//!
3//! The simulator is intentionally separate from the user-facing `ciri`
4//! pipeline. It reads FASTA/GTF directly, samples circular isoforms and linear
5//! background reads, writes paired FASTQ, emits a linear-only annotation GTF, and
6//! records two truth tables: one row per circRNA and one row per read pair. The
7//! output contract follows `docs/06-simulation-truth-design.md` and is designed
8//! for CIRI-AS-style internal-structure/full-length isoform validation rather
9//! than for reproducing the historical Perl simulator files.
10
11use crate::fasta::FastaReader;
12use anyhow::{bail, Context, Result};
13use clap::Parser;
14use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
15use std::env;
16use std::f64::consts::TAU;
17use std::fs::{create_dir_all, remove_file, File};
18use std::io::{BufRead, BufReader, BufWriter, Write};
19use std::path::{Path, PathBuf};
20use std::process::{Command, Stdio};
21
22/// Probability used by the initial simulator model to add a second isoform.
23const SECOND_ISOFORM_PROBABILITY: f64 = 0.5;
24/// Major isoform usage assigned to the first sample in simulated switching events.
25const SWITCHING_HIGH_USAGE: f64 = 0.85;
26/// Minor isoform usage assigned to the first sample in simulated switching events.
27const SWITCHING_LOW_USAGE: f64 = 0.15;
28/// Stable major usage used for non-switching multi-sample circRNAs.
29const NON_SWITCHING_MAJOR_USAGE: f64 = 0.90;
30
31/// CircRNAs at or below this length use an insert distribution centered near
32/// the circle length so small circles can generate full-length-supporting read
33/// pairs at useful rates.
34const SHORT_CIRC_FULL_LENGTH_MAX_LEN: usize = 300;
35
36/// Fraction of short-circ fragments drawn from the full-length-enriched insert
37/// distribution instead of the ordinary PE mixture.
38const SHORT_CIRC_FULL_LENGTH_BIAS_FRACTION: f64 = 0.70;
39
40/// Minimum coordinate displacement required before simulator truth calls a
41/// non-BSJ circular pair `type=outward`.
42///
43/// The simulator does not have mapper MAPQ, alternative-hit, or soft-clip
44/// evidence, so truth-side outward is intentionally a stricter pair-geometry
45/// proxy: both outward-facing block boundaries must shift by at least the same
46/// 19 bp anchor used by BSJ-style terminal evidence.
47const OUTWARD_TRUTH_MIN_PAIR_OFFSET: usize = 19;
48
49/// Command-line options for the standalone simulator.
50///
51/// Defaults approximate a compact PE150 RNA-seq fixture. The circular and
52/// linear abundance knobs are coverage means; sampled values are converted into
53/// paired-end read-pair counts by the fixed formulas in the design document.
54#[derive(Parser, Debug, Clone)]
55#[command(
56    author,
57    version,
58    about = "Generate CIRI simulator FASTQ and truth tables",
59    long_about = None
60)]
61pub struct SimulateArgs {
62    /// Reference genome FASTA.
63    #[arg(short = 'r', long = "ref")]
64    pub ref_fasta: String,
65
66    /// Source GTF annotation used to choose circular and linear transcript exon chains.
67    #[arg(short = 'a', long = "anno")]
68    pub gtf: String,
69
70    /// Output prefix; pass two prefixes to generate a matched switching pair.
71    #[arg(short = 'o', long = "out", num_args = 1..)]
72    pub out_prefixes: Vec<String>,
73
74    /// Optional chromosome filter, for example `chr1`.
75    #[arg(long = "chrom")]
76    pub chrom: Option<String>,
77
78    /// Number of circular RNA loci to simulate.
79    #[arg(long = "circ-count", default_value_t = 100)]
80    pub circ_count: usize,
81
82    /// Number of two-isoform circRNAs whose major isoform switches between two samples.
83    #[arg(long = "switching-event", default_value_t = 0)]
84    pub switching_event: usize,
85
86    /// Mean circRNA coverage; per-circ coverage is sampled with `--scale`.
87    #[arg(long = "circ-coverage", default_value_t = 10.0)]
88    pub circ_coverage: f64,
89
90    /// Mean linear transcript coverage used to size the background read set.
91    #[arg(long = "linear-coverage", default_value_t = 0.1)]
92    pub linear_coverage: f64,
93
94    /// Relative standard deviation for Gaussian coverage sampling.
95    #[arg(long = "scale", default_value_t = 0.5)]
96    pub scale: f64,
97
98    /// Read length for ordinary paired-end reads.
99    #[arg(long = "read-len", default_value_t = 150)]
100    pub read_len: usize,
101
102    /// Mean fragment span in transcript/circular coordinates.
103    #[arg(long = "insert-len", default_value_t = 260)]
104    pub insert_len: usize,
105
106    /// Standard deviation of the primary fragment span distribution.
107    #[arg(long = "insert-sd", default_value_t = 40.0)]
108    pub insert_sd: f64,
109
110    /// Mean fragment span for the minor long-fragment distribution.
111    #[arg(long = "insert-len-minor", default_value_t = 420)]
112    pub insert_len_minor: usize,
113
114    /// Standard deviation of the minor long-fragment distribution.
115    #[arg(long = "insert-sd-minor", default_value_t = 60.0)]
116    pub insert_sd_minor: f64,
117
118    /// Fraction of fragments sampled from the minor long-fragment distribution.
119    #[arg(long = "minor-insert-fraction", default_value_t = 0.10)]
120    pub minor_insert_fraction: f64,
121
122    /// Per-base substitution probability for ordinary reads.
123    #[arg(long = "error-rate", default_value_t = 0.002)]
124    pub error_rate: f64,
125
126    /// Per-unique-exon probability of marking an exon coordinate circRNA-exclusive.
127    #[arg(long = "exon-exclusive-rate", default_value_t = 0.25)]
128    pub exon_exclusive_rate: f64,
129
130    /// Deterministic seed controlling annotation sampling, isoforms and reads.
131    #[arg(long = "seed", default_value_t = 5)]
132    pub seed: u64,
133}
134
135/// User-facing summary returned by one simulator run.
136///
137/// The display text is intentionally stable because development scripts use it
138/// for quick fixture sanity checks. Count wording is explicit: total/circ/linear
139/// values are paired-end read pairs, while the first BSJ value counts individual
140/// mates crossing the circular boundary.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct SimulationSummary {
143    /// Number of output samples.
144    pub sample_count: usize,
145    /// Number of simulated circRNA loci.
146    pub circ_count: usize,
147    /// Number of circular isoforms across all simulated circRNA loci.
148    pub isoform_count: usize,
149    /// Number of paired-end read pairs written to FASTQ.
150    pub total_read_pairs: usize,
151    /// Number of read pairs sampled from circular isoforms.
152    pub circ_read_pairs: usize,
153    /// Number of read pairs sampled from the filtered linear annotation.
154    pub linear_read_pairs: usize,
155    /// Number of individual mates crossing a circular boundary.
156    pub bsj_reads: usize,
157    /// Number of read pairs where at least one mate crosses a circular boundary.
158    pub bsj_read_pairs: usize,
159}
160
161impl std::fmt::Display for SimulationSummary {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        if self.sample_count <= 1 {
164            write!(
165                f,
166                "Simulated {} circRNAs, {} circular isoforms\nTotal {} read pairs, {} circRNA read pairs, {} linear read pairs\nBSJ feature: {} reads / {} read pairs",
167                self.circ_count,
168                self.isoform_count,
169                self.total_read_pairs,
170                self.circ_read_pairs,
171                self.linear_read_pairs,
172                self.bsj_reads,
173                self.bsj_read_pairs
174            )
175        } else {
176            write!(
177                f,
178                "Simulated {} samples, {} circRNAs, {} circular isoforms\nTotal {} read pairs, {} circRNA read pairs, {} linear read pairs\nBSJ feature: {} reads / {} read pairs",
179                self.sample_count,
180                self.circ_count,
181                self.isoform_count,
182                self.total_read_pairs,
183                self.circ_read_pairs,
184                self.linear_read_pairs,
185                self.bsj_reads,
186                self.bsj_read_pairs
187            )
188        }
189    }
190}
191
192/// One deduplicated exon coordinate used as a sampling unit.
193///
194/// `gene_id` and `transcript_id` are deliberately excluded so shared genomic
195/// exons across transcripts or genes are marked exclusive together.
196#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
197struct ExonKey {
198    chrom: String,
199    start: usize,
200    end: usize,
201    strand: char,
202}
203
204/// One exon interval from the source GTF.
205#[derive(Clone, Debug)]
206struct Exon {
207    start: usize,
208    end: usize,
209    exclusive: bool,
210}
211
212impl Exon {
213    /// Returns the 1-based closed interval length.
214    fn len(&self) -> usize {
215        self.end.saturating_sub(self.start).saturating_add(1)
216    }
217}
218
219/// Transcript-level exon chain used by circular or linear read simulation.
220#[derive(Clone, Debug)]
221struct Transcript {
222    chrom: String,
223    gene_id: String,
224    transcript_id: String,
225    strand: char,
226    exons: Vec<Exon>,
227}
228
229/// Parsed GTF record needed to write the filtered linear annotation.
230///
231/// Storing the original line lets the simulator preserve raw GTF order and
232/// attributes for surviving `gene`, `transcript` and `exon` records.
233#[derive(Clone, Debug)]
234struct GtfRecord {
235    line: String,
236    feature: String,
237    gene_id: Option<String>,
238    transcript_id: Option<String>,
239    exon_key: Option<ExonKey>,
240}
241
242/// Parsed annotation state after circRNA-exclusive exon sampling.
243#[derive(Clone, Debug)]
244struct AnnotationModel {
245    records: Vec<GtfRecord>,
246    transcripts: Vec<Transcript>,
247    linear_transcripts: Vec<Transcript>,
248    exclusive_exons: BTreeSet<ExonKey>,
249    surviving_genes: HashSet<String>,
250    surviving_transcripts: HashSet<String>,
251}
252
253/// One simulated circRNA locus.
254#[derive(Clone, Debug)]
255struct SimCirc {
256    circ_id: String,
257    chrom: String,
258    start: usize,
259    end: usize,
260    strand: char,
261    gene_id: String,
262    transcript_id: String,
263    isoforms: Vec<SimIsoform>,
264    sampled_circ_coverage: f64,
265    read_pair_count: usize,
266    bsj_read_pair_count: usize,
267    bsj_read_count: usize,
268}
269
270/// One transcript-derived circular isoform and its read counters.
271#[derive(Clone, Debug)]
272struct SimIsoform {
273    isoform_id: String,
274    exon_chain: Vec<Exon>,
275    seq: String,
276    read_pair_count: usize,
277    bsj_read_pair_count: usize,
278}
279
280/// Per-sample isoform usage for one circRNA.
281///
282/// Multi-sample fixtures keep circRNA structures fixed across samples and only
283/// vary these weights. This creates a clean truth source for downstream
284/// isoform-usage and major-switching validation.
285#[derive(Clone, Debug)]
286struct SampleCircUsage {
287    weights: Vec<f64>,
288}
289
290/// Isoform usage profile for one output sample.
291#[derive(Clone, Debug)]
292struct SampleUsageProfile {
293    prefix: String,
294    by_circ: Vec<SampleCircUsage>,
295}
296
297/// Per-base source coordinate in transcript-oriented sequence space.
298#[derive(Clone, Debug)]
299struct SourceBase {
300    coord: usize,
301    exon_idx: usize,
302}
303
304/// Segment protocol fields for one mate.
305#[derive(Clone, Debug)]
306struct MateSegments {
307    text: String,
308    is_bsj: bool,
309}
310
311/// One contiguous genomic segment produced while traversing simulated source bases.
312#[derive(Clone, Debug)]
313struct SegmentToken {
314    start: usize,
315    end: usize,
316    partition: usize,
317}
318
319/// Small deterministic RNG so the simulator does not add another crate-level dependency.
320#[derive(Clone, Debug)]
321struct Lcg64 {
322    state: u64,
323}
324
325impl Lcg64 {
326    /// Creates a deterministic generator from the CLI seed.
327    fn new(seed: u64) -> Self {
328        Self {
329            state: seed ^ 0x9E37_79B9_7F4A_7C15,
330        }
331    }
332
333    /// Advances the generator and returns the next pseudo-random value.
334    fn next_u64(&mut self) -> u64 {
335        self.state = self
336            .state
337            .wrapping_mul(6364136223846793005)
338            .wrapping_add(1442695040888963407);
339        self.state
340    }
341
342    /// Returns a value in `0..upper`.
343    fn gen_range(&mut self, upper: usize) -> usize {
344        if upper <= 1 {
345            0
346        } else {
347            (self.next_u64() as usize) % upper
348        }
349    }
350
351    /// Returns a floating point value in `[0, 1)`.
352    fn gen_f64(&mut self) -> f64 {
353        const SCALE: f64 = (1u64 << 53) as f64;
354        ((self.next_u64() >> 11) as f64) / SCALE
355    }
356
357    /// Samples one standard normal value with Box-Muller.
358    fn gen_standard_normal(&mut self) -> f64 {
359        let u1 = self.gen_f64().clamp(f64::MIN_POSITIVE, 1.0 - f64::EPSILON);
360        let u2 = self.gen_f64();
361        (-2.0 * u1.ln()).sqrt() * (TAU * u2).cos()
362    }
363}
364
365/// Creates an output path by appending a stable suffix to the user prefix.
366fn prefixed_path(prefix: &str, suffix: &str) -> PathBuf {
367    PathBuf::from(format!("{}{}", prefix, suffix))
368}
369
370/// Extracts a quoted GTF attribute such as `gene_id` or `transcript_id`.
371fn attr_value(attrs: &str, key: &str) -> Option<String> {
372    for item in attrs.split(';') {
373        let trimmed = item.trim();
374        if !trimmed.starts_with(key) {
375            continue;
376        }
377        return trimmed.split('"').nth(1).map(str::to_string);
378    }
379    None
380}
381
382/// Parses the GTF, samples circRNA-exclusive unique exon coordinates, and builds models.
383///
384/// The exclusive sampling unit is `(chrom, start, end, strand)`. All records
385/// sharing that coordinate are removed from the filtered linear annotation and
386/// from linear read simulation, while circular simulation continues to use the
387/// complete source transcript set.
388fn read_annotation_model(
389    gtf: &str,
390    chrom_filter: Option<&str>,
391    exon_exclusive_rate: f64,
392    rng: &mut Lcg64,
393) -> Result<AnnotationModel> {
394    let file = File::open(gtf).with_context(|| format!("cannot open GTF: {gtf}"))?;
395    let reader = BufReader::new(file);
396    let mut records = Vec::new();
397    let mut transcript_exons: BTreeMap<(String, String, String), (char, Vec<(ExonKey, Exon)>)> =
398        BTreeMap::new();
399    let mut unique_exons = BTreeSet::new();
400
401    for line_res in reader.lines() {
402        let line = line_res?;
403        if line.starts_with('#') || line.trim().is_empty() {
404            continue;
405        }
406        let fields: Vec<&str> = line.split('\t').collect();
407        if fields.len() < 9 {
408            continue;
409        }
410        if let Some(chrom) = chrom_filter {
411            if fields[0] != chrom {
412                continue;
413            }
414        }
415        let feature = fields[2].to_string();
416        if !matches!(feature.as_str(), "gene" | "transcript" | "exon") {
417            continue;
418        }
419        let chrom = fields[0].to_string();
420        let start = fields[3].parse::<usize>()?;
421        let end = fields[4].parse::<usize>()?;
422        let strand = fields[6].chars().next().unwrap_or('+');
423        let gene_id = attr_value(fields[8], "gene_id");
424        let transcript_id = attr_value(fields[8], "transcript_id");
425        let exon_key = (feature == "exon").then(|| ExonKey {
426            chrom: chrom.clone(),
427            start,
428            end,
429            strand,
430        });
431
432        if let Some(key) = &exon_key {
433            unique_exons.insert(key.clone());
434            let gene = gene_id.clone().unwrap_or_else(|| "NA".to_string());
435            let tx = transcript_id
436                .clone()
437                .unwrap_or_else(|| format!("{gene}:tx"));
438            transcript_exons
439                .entry((chrom.clone(), gene, tx))
440                .or_insert_with(|| (strand, Vec::new()))
441                .1
442                .push((
443                    key.clone(),
444                    Exon {
445                        start,
446                        end,
447                        exclusive: false,
448                    },
449                ));
450        }
451
452        records.push(GtfRecord {
453            line,
454            feature,
455            gene_id,
456            transcript_id,
457            exon_key,
458        });
459    }
460
461    let mut exclusive_exons = BTreeSet::new();
462    for exon in &unique_exons {
463        if rng.gen_f64() <= exon_exclusive_rate {
464            exclusive_exons.insert(exon.clone());
465        }
466    }
467
468    let mut transcripts = Vec::new();
469    let mut linear_transcripts = Vec::new();
470    let mut surviving_genes = HashSet::new();
471    let mut surviving_transcripts = HashSet::new();
472
473    for ((chrom, gene_id, transcript_id), (strand, exon_items)) in transcript_exons {
474        let mut full_exons = Vec::new();
475        let mut linear_exons = Vec::new();
476        for (key, mut exon) in exon_items {
477            exon.exclusive = exclusive_exons.contains(&key);
478            full_exons.push(exon.clone());
479            if !exon.exclusive {
480                linear_exons.push(exon);
481            }
482        }
483        full_exons.sort_by_key(|exon| exon.start);
484        linear_exons.sort_by_key(|exon| exon.start);
485        if !full_exons.is_empty() {
486            transcripts.push(Transcript {
487                chrom: chrom.clone(),
488                gene_id: gene_id.clone(),
489                transcript_id: transcript_id.clone(),
490                strand,
491                exons: full_exons,
492            });
493        }
494        if !linear_exons.is_empty() {
495            surviving_genes.insert(gene_id.clone());
496            surviving_transcripts.insert(transcript_id.clone());
497            linear_transcripts.push(Transcript {
498                chrom,
499                gene_id,
500                transcript_id,
501                strand,
502                exons: linear_exons,
503            });
504        }
505    }
506
507    Ok(AnnotationModel {
508        records,
509        transcripts,
510        linear_transcripts,
511        exclusive_exons,
512        surviving_genes,
513        surviving_transcripts,
514    })
515}
516
517/// Writes the linear annotation GTF after removing circRNA-exclusive exon coordinates.
518///
519/// Only `gene`, `transcript` and `exon` records are emitted, and the surviving
520/// records are written in the same order as the raw input GTF so annotation
521/// diffs are deterministic and easy to inspect.
522fn write_linear_annotation(model: &AnnotationModel, prefix: &str) -> Result<()> {
523    let mut writer = BufWriter::new(File::create(prefixed_path(prefix, ".annotation.gtf"))?);
524    for record in &model.records {
525        match record.feature.as_str() {
526            "gene" => {
527                let Some(gene_id) = &record.gene_id else {
528                    continue;
529                };
530                if model.surviving_genes.contains(gene_id) {
531                    writeln!(writer, "{}", record.line)?;
532                }
533            }
534            "transcript" => {
535                let Some(transcript_id) = &record.transcript_id else {
536                    continue;
537                };
538                if model.surviving_transcripts.contains(transcript_id) {
539                    writeln!(writer, "{}", record.line)?;
540                }
541            }
542            "exon" => {
543                let Some(exon_key) = &record.exon_key else {
544                    continue;
545                };
546                let Some(transcript_id) = &record.transcript_id else {
547                    continue;
548                };
549                if !model.exclusive_exons.contains(exon_key)
550                    && model.surviving_transcripts.contains(transcript_id)
551                {
552                    writeln!(writer, "{}", record.line)?;
553                }
554            }
555            _ => {}
556        }
557    }
558    Ok(())
559}
560
561/// Returns reverse-complemented DNA, mapping non-ACGT bases to `N`.
562fn revcomp(seq: &str) -> String {
563    seq.as_bytes()
564        .iter()
565        .rev()
566        .map(|base| match base.to_ascii_uppercase() {
567            b'A' => 'T',
568            b'T' => 'A',
569            b'C' => 'G',
570            b'G' => 'C',
571            _ => 'N',
572        })
573        .collect()
574}
575
576/// Slices one 1-based closed genomic exon interval from the reference.
577fn genomic_slice<'a>(
578    reference: &'a HashMap<String, String>,
579    chrom: &str,
580    exon: &Exon,
581) -> Result<&'a str> {
582    let chr_seq = reference
583        .get(chrom)
584        .with_context(|| format!("reference FASTA does not contain chromosome {chrom}"))?;
585    if exon.start == 0 || exon.end > chr_seq.len() || exon.start > exon.end {
586        bail!("invalid exon interval {chrom}:{}-{}", exon.start, exon.end);
587    }
588    Ok(&chr_seq[exon.start - 1..exon.end])
589}
590
591/// Builds transcript-oriented sequence for a selected exon chain.
592///
593/// Negative-strand transcripts are emitted in reverse genomic exon order with
594/// each exon reverse-complemented, so sampled offsets follow transcript/read
595/// orientation while truth coordinates remain 1-based closed genomic intervals.
596fn isoform_sequence(
597    reference: &HashMap<String, String>,
598    chrom: &str,
599    strand: char,
600    exons: &[Exon],
601) -> Result<String> {
602    let mut seq = String::new();
603    if strand == '-' {
604        for exon in exons.iter().rev() {
605            seq.push_str(&revcomp(genomic_slice(reference, chrom, exon)?));
606        }
607    } else {
608        for exon in exons {
609            seq.push_str(genomic_slice(reference, chrom, exon)?);
610        }
611    }
612    Ok(seq)
613}
614
615/// Builds a per-base coordinate map in transcript-oriented sequence order.
616///
617/// The map lets read truth convert simulated offsets back into compact genomic
618/// segment tokens without guessing across exon boundaries.
619fn source_map_for_exons(exons: &[Exon], strand: char) -> Vec<SourceBase> {
620    let mut map = Vec::new();
621    if strand == '-' {
622        for (exon_idx, exon) in exons.iter().enumerate().rev() {
623            for coord in (exon.start..=exon.end).rev() {
624                map.push(SourceBase { coord, exon_idx });
625            }
626        }
627    } else {
628        for (exon_idx, exon) in exons.iter().enumerate() {
629            for coord in exon.start..=exon.end {
630                map.push(SourceBase { coord, exon_idx });
631            }
632        }
633    }
634    map
635}
636
637/// Formats exon coordinates in the compact `start-end:strand` protocol.
638fn exon_chain_string(exons: &[Exon], strand: char) -> String {
639    exons
640        .iter()
641        .map(|exon| {
642            let suffix = if exon.exclusive { "*" } else { "" };
643            format!("{}-{}:{}{}", exon.start, exon.end, strand, suffix)
644        })
645        .collect::<Vec<_>>()
646        .join(",")
647}
648
649/// Takes a sequence slice in circular coordinate space.
650fn circular_slice(seq: &str, start: usize, len: usize) -> String {
651    let bytes = seq.as_bytes();
652    let mut out = String::with_capacity(len);
653    for offset in 0..len {
654        out.push(bytes[(start + offset) % bytes.len()] as char);
655    }
656    out
657}
658
659/// Takes a linear slice if it fits entirely within the sequence.
660fn linear_slice(seq: &str, start: usize, len: usize) -> Option<&str> {
661    seq.get(start..start + len)
662}
663
664/// Returns whether an interval crosses the circular boundary in template space.
665fn crosses_boundary(start: usize, len: usize, seq_len: usize) -> bool {
666    start + len > seq_len
667}
668
669/// Formats mate segments for one simulated read.
670///
671/// The simulator truth emits segments in read-chain order.
672///
673/// This preserves circRNA topology. A plus-strand BSJ read is represented as
674/// the circ-end-side source blocks followed by `<bsj>` and then the
675/// circ-start-side blocks; negative-strand isoforms use the transcript-oriented
676/// source map, so walking forward through `source_map` gives the comparable
677/// read-chain order after reverse-complement normalization.
678fn format_segments(
679    source_map: &[SourceBase],
680    strand: char,
681    start: usize,
682    len: usize,
683    circular: bool,
684) -> MateSegments {
685    let seq_len = source_map.len();
686    let positions: Vec<usize> = (0..len)
687        .map(|offset| {
688            if circular {
689                (start + offset) % seq_len
690            } else {
691                start + offset
692            }
693        })
694        .collect();
695    let mut segments = Vec::new();
696    let mut current: Option<(usize, usize, usize, usize)> = None;
697    let mut is_bsj = false;
698    let mut partition = 0usize;
699    let flush_current =
700        |segments: &mut Vec<SegmentToken>, current: &mut Option<(usize, usize, usize, usize)>| {
701            if let Some((min_coord, max_coord, _exon_idx, partition)) = current.take() {
702                segments.push(SegmentToken {
703                    start: min_coord,
704                    end: max_coord,
705                    partition,
706                });
707            }
708        };
709
710    for (idx, pos) in positions.iter().copied().enumerate() {
711        if idx > 0 && circular {
712            let prev = positions[idx - 1];
713            if (prev == seq_len - 1 && pos == 0) || (prev == 0 && pos == seq_len - 1) {
714                flush_current(&mut segments, &mut current);
715                partition += 1;
716                is_bsj = true;
717            }
718        }
719
720        let base = &source_map[pos];
721        match current {
722            Some((ref mut min_coord, ref mut max_coord, exon_idx, existing_partition))
723                if exon_idx == base.exon_idx
724                    && existing_partition == partition
725                    && (base.coord.abs_diff(*min_coord) == 1
726                        || base.coord.abs_diff(*max_coord) == 1) =>
727            {
728                *min_coord = (*min_coord).min(base.coord);
729                *max_coord = (*max_coord).max(base.coord);
730            }
731            Some(_) => {
732                flush_current(&mut segments, &mut current);
733                current = Some((base.coord, base.coord, base.exon_idx, partition));
734            }
735            None => current = Some((base.coord, base.coord, base.exon_idx, partition)),
736        }
737    }
738    flush_current(&mut segments, &mut current);
739
740    let mut out = Vec::with_capacity(segments.len() + usize::from(is_bsj));
741    let mut previous_partition = None;
742    for segment in segments {
743        if previous_partition.is_some_and(|partition| partition != segment.partition) {
744            out.push("<bsj>".to_string());
745        }
746        out.push(format!("{}-{}:{strand}", segment.start, segment.end));
747        previous_partition = Some(segment.partition);
748    }
749    let text = out.join("|");
750
751    MateSegments { text, is_bsj }
752}
753
754/// Returns the genomic span of the first read-chain block for one mate.
755///
756/// This mirrors the first mapper-visible block used by the simulator's
757/// pair-orientation truth rule. The first block is enough here because
758/// `type=outward` is a pair-level orientation class; downstream exon-junction
759/// support is still carried by the full segment chain.
760fn first_read_chain_segment_span(
761    source_map: &[SourceBase],
762    start: usize,
763    len: usize,
764    circular: bool,
765) -> Option<(usize, usize)> {
766    let seq_len = source_map.len();
767    let mut iter = (0..len).map(|offset| {
768        if circular {
769            (start + offset) % seq_len
770        } else {
771            start + offset
772        }
773    });
774    let first_pos = iter.next()?;
775    let first = &source_map[first_pos];
776    let mut min_coord = first.coord;
777    let mut max_coord = first.coord;
778    let exon_idx = first.exon_idx;
779    let mut prev_pos = first_pos;
780
781    for pos in iter {
782        if circular
783            && ((prev_pos == seq_len - 1 && pos == 0) || (prev_pos == 0 && pos == seq_len - 1))
784        {
785            break;
786        }
787        let base = &source_map[pos];
788        if base.exon_idx != exon_idx
789            || (base.coord.abs_diff(min_coord) != 1 && base.coord.abs_diff(max_coord) != 1)
790        {
791            break;
792        }
793        min_coord = min_coord.min(base.coord);
794        max_coord = max_coord.max(base.coord);
795        prev_pos = pos;
796    }
797
798    Some((min_coord, max_coord))
799}
800
801/// Tests the simulator-side outward-facing pair truth geometry.
802///
803/// R1 maps in the transcript strand direction, while R2 maps in the opposite
804/// direction because its emitted sequence is reverse-complemented. A simulator
805/// `type=outward` pair requires the reverse-oriented block to sit left of the
806/// forward-oriented block with a clear 19 bp displacement at both block
807/// boundaries. Overlap is not required because the truth label describes
808/// observable pair orientation, not a RO-overlap subtype; however tiny shifts
809/// are left as `forward` so evaluation does not reward ambiguous outward calls.
810fn is_outward_facing_truth(
811    source_map: &[SourceBase],
812    strand: char,
813    r1_start: usize,
814    r2_start: usize,
815    read_len: usize,
816) -> bool {
817    let Some(r1_span) = first_read_chain_segment_span(source_map, r1_start, read_len, true) else {
818        return false;
819    };
820    let Some(r2_span) = first_read_chain_segment_span(source_map, r2_start, read_len, true) else {
821        return false;
822    };
823    let (reverse_span, forward_span) = if strand == '-' {
824        (r1_span, r2_span)
825    } else {
826        (r2_span, r1_span)
827    };
828    let start_offset = forward_span.0.saturating_sub(reverse_span.0);
829    let end_offset = forward_span.1.saturating_sub(reverse_span.1);
830    start_offset >= OUTWARD_TRUTH_MIN_PAIR_OFFSET && end_offset >= OUTWARD_TRUTH_MIN_PAIR_OFFSET
831}
832
833/// Samples a non-negative coverage value from the configured Gaussian model.
834fn sample_coverage(mean: f64, scale: f64, rng: &mut Lcg64) -> f64 {
835    if mean <= 0.0 {
836        return 0.0;
837    }
838    if scale <= 0.0 {
839        return mean;
840    }
841    (mean + rng.gen_standard_normal() * mean * scale).max(0.0)
842}
843
844/// Resolves the number of circular read pairs for one circRNA.
845///
846/// The `max(1, ...)` rule makes `--circ-count` mean "circRNAs with at least one
847/// circular read pair" and is part of the simulator truth contract.
848fn circular_pair_count(circ_len: usize, coverage: f64, read_len: usize) -> usize {
849    ((circ_len as f64 * coverage) / (read_len as f64 * 2.0))
850        .round()
851        .max(1.0) as usize
852}
853
854/// Resolves the linear background size from sampled transcript coverage.
855fn linear_pair_target(
856    transcripts: &[Transcript],
857    args: &SimulateArgs,
858    sampled_linear_coverage: f64,
859) -> usize {
860    let total_bases: usize = transcripts
861        .iter()
862        .flat_map(|tx| tx.exons.iter())
863        .map(Exon::len)
864        .sum();
865    ((total_bases as f64 * sampled_linear_coverage) / (args.read_len as f64 * 2.0))
866        .round()
867        .max(0.0) as usize
868}
869
870/// Samples a PE-style fragment span from a primary/minor normal mixture.
871///
872/// The result is clamped to at least the read length because paired-end geometry
873/// in this simulator represents an outer fragment span.
874fn sample_insert_len(args: &SimulateArgs, rng: &mut Lcg64) -> usize {
875    let use_minor = rng.gen_f64() < args.minor_insert_fraction;
876    let (mean, sd) = if use_minor {
877        (args.insert_len_minor as f64, args.insert_sd_minor)
878    } else {
879        (args.insert_len as f64, args.insert_sd)
880    };
881    let sampled = mean + rng.gen_standard_normal() * sd;
882    sampled.round().max(args.read_len as f64) as usize
883}
884
885/// Samples a fragment span for one circular molecule.
886fn sample_circular_insert_len(seq_len: usize, args: &SimulateArgs, rng: &mut Lcg64) -> usize {
887    if seq_len > SHORT_CIRC_FULL_LENGTH_MAX_LEN
888        || rng.gen_f64() >= SHORT_CIRC_FULL_LENGTH_BIAS_FRACTION
889    {
890        return sample_insert_len(args, rng);
891    }
892
893    let mean = seq_len as f64;
894    let sd = ((seq_len as f64) * 0.06).max(8.0);
895    let lower = args.read_len as f64;
896    let upper = (seq_len + args.read_len / 3).max(args.read_len) as f64;
897    let sampled = mean + rng.gen_standard_normal() * sd;
898    sampled.round().clamp(lower, upper) as usize
899}
900
901/// Applies simple substitution errors to sampled reads.
902fn mutate_read(seq: &str, error_rate: f64, rng: &mut Lcg64) -> String {
903    if error_rate <= 0.0 {
904        return seq.to_string();
905    }
906    let mut out = String::with_capacity(seq.len());
907    for base in seq.bytes() {
908        let upper = base.to_ascii_uppercase();
909        if !matches!(upper, b'A' | b'C' | b'G' | b'T') || rng.gen_f64() >= error_rate {
910            out.push(upper as char);
911            continue;
912        }
913        let choices = match upper {
914            b'A' => [b'C', b'G', b'T'],
915            b'C' => [b'A', b'G', b'T'],
916            b'G' => [b'A', b'C', b'T'],
917            b'T' => [b'A', b'C', b'G'],
918            _ => unreachable!(),
919        };
920        out.push(choices[rng.gen_range(choices.len())] as char);
921    }
922    out
923}
924
925/// Writes one FASTQ record with stable metadata in the header.
926fn write_fastq(
927    writer: &mut BufWriter<File>,
928    read_id: &str,
929    seq: &str,
930    qual: &str,
931    metadata: &str,
932) -> Result<()> {
933    writeln!(writer, "@{read_id} {metadata}")?;
934    writeln!(writer, "{seq}")?;
935    writeln!(writer, "+")?;
936    writeln!(writer, "{qual}")?;
937    Ok(())
938}
939
940#[derive(Debug, Clone, Copy, PartialEq, Eq)]
941enum FastqCompressor {
942    Pigz,
943    Gzip,
944}
945
946impl FastqCompressor {
947    /// Returns the external program name used for compression.
948    fn program(self) -> &'static str {
949        match self {
950            Self::Pigz => "pigz",
951            Self::Gzip => "gzip",
952        }
953    }
954}
955
956/// Selects the external FASTQ compressor before simulation work starts.
957fn select_fastq_compressor() -> Result<FastqCompressor> {
958    let compressor = select_fastq_compressor_from_availability(
959        executable_exists("pigz"),
960        executable_exists("gzip"),
961    )?;
962    if compressor == FastqCompressor::Gzip {
963        eprintln!("pigz not found; falling back to gzip for simulator FASTQ compression");
964    }
965    Ok(compressor)
966}
967
968/// Returns whether an executable can be found in `PATH`.
969fn executable_exists(program: &str) -> bool {
970    if program.is_empty() {
971        return false;
972    }
973    let Some(paths) = env::var_os("PATH") else {
974        return false;
975    };
976    env::split_paths(&paths).any(|dir| is_executable_file(&dir.join(program)))
977}
978
979#[cfg(unix)]
980fn is_executable_file(path: &Path) -> bool {
981    use std::os::unix::fs::PermissionsExt;
982
983    path.is_file()
984        && path
985            .metadata()
986            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
987            .unwrap_or(false)
988}
989
990#[cfg(not(unix))]
991fn is_executable_file(path: &Path) -> bool {
992    path.is_file()
993}
994
995fn select_fastq_compressor_from_availability(
996    has_pigz: bool,
997    has_gzip: bool,
998) -> Result<FastqCompressor> {
999    if has_pigz {
1000        Ok(FastqCompressor::Pigz)
1001    } else if has_gzip {
1002        Ok(FastqCompressor::Gzip)
1003    } else {
1004        bail!("failed to compress simulator FASTQ: neither pigz nor gzip was found in PATH")
1005    }
1006}
1007
1008/// Compresses one completed simulator FASTQ with the selected compressor.
1009fn compress_fastq_to_gz(
1010    input_path: &Path,
1011    output_path: &Path,
1012    compressor: FastqCompressor,
1013) -> Result<()> {
1014    run_fastq_compressor(compressor.program(), input_path, output_path)?;
1015    remove_file(input_path)
1016        .with_context(|| format!("remove temporary FASTQ {}", input_path.display()))?;
1017    Ok(())
1018}
1019
1020/// Runs one external FASTQ compressor and fails if it cannot produce output.
1021fn run_fastq_compressor(program: &str, input_path: &Path, output_path: &Path) -> Result<()> {
1022    let output = File::create(output_path)
1023        .with_context(|| format!("create compressed FASTQ {}", output_path.display()))?;
1024    let status = match Command::new(program)
1025        .arg("-n")
1026        .arg("-c")
1027        .arg(input_path)
1028        .stdout(Stdio::from(output))
1029        .stderr(Stdio::null())
1030        .status()
1031    {
1032        Ok(status) => status,
1033        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1034            let _ = remove_file(output_path);
1035            bail!("{program} disappeared before FASTQ compression could run");
1036        }
1037        Err(err) => {
1038            let _ = remove_file(output_path);
1039            return Err(err).with_context(|| format!("run {program}"));
1040        }
1041    };
1042
1043    if status.success() {
1044        Ok(())
1045    } else {
1046        let _ = remove_file(output_path);
1047        bail!(
1048            "{program} exited with {status} while compressing {}",
1049            input_path.display()
1050        )
1051    }
1052}
1053
1054/// Selects circular exon windows from full source transcripts.
1055fn build_circs(
1056    transcripts: &[Transcript],
1057    reference: &HashMap<String, String>,
1058    args: &SimulateArgs,
1059    rng: &mut Lcg64,
1060) -> Result<Vec<SimCirc>> {
1061    let min_len = args.read_len.max(80);
1062    let mut candidates: Vec<usize> = transcripts
1063        .iter()
1064        .enumerate()
1065        .filter_map(|(idx, tx)| (tx.exons.len() >= 2).then_some(idx))
1066        .collect();
1067    for i in (1..candidates.len()).rev() {
1068        let j = rng.gen_range(i + 1);
1069        candidates.swap(i, j);
1070    }
1071
1072    let mut circs = Vec::new();
1073    let mut switching_candidate_count = 0usize;
1074    for tx_idx in candidates {
1075        if circs.len() >= args.circ_count {
1076            break;
1077        }
1078        let tx = &transcripts[tx_idx];
1079        let max_window = tx.exons.len().min(8);
1080        let min_window = 2;
1081        if max_window < min_window {
1082            continue;
1083        }
1084        let window_len = min_window + rng.gen_range(max_window - min_window + 1);
1085        let window_start = rng.gen_range(tx.exons.len() - window_len + 1);
1086        let mut exon_window = tx.exons[window_start..window_start + window_len].to_vec();
1087        exon_window.sort_by_key(|exon| exon.start);
1088
1089        let seq = isoform_sequence(reference, &tx.chrom, tx.strand, &exon_window)?;
1090        if seq.len() < min_len {
1091            continue;
1092        }
1093        let sampled_circ_coverage = sample_coverage(args.circ_coverage, args.scale, rng);
1094        let read_pair_count = circular_pair_count(seq.len(), sampled_circ_coverage, args.read_len);
1095        let start = exon_window.iter().map(|exon| exon.start).min().unwrap();
1096        let end = exon_window.iter().map(|exon| exon.end).max().unwrap();
1097        let circ_id = format!("{}:{}|{}", tx.chrom, start, end);
1098        let mut isoforms = vec![SimIsoform {
1099            isoform_id: "isoform1".to_string(),
1100            exon_chain: exon_window.clone(),
1101            seq,
1102            read_pair_count: 0,
1103            bsj_read_pair_count: 0,
1104        }];
1105
1106        let force_switching_candidate =
1107            args.out_prefixes.len() > 1 && switching_candidate_count < args.switching_event;
1108        if exon_window.len() >= 3
1109            && read_pair_count >= 2
1110            && (force_switching_candidate || rng.gen_f64() < SECOND_ISOFORM_PROBABILITY)
1111        {
1112            let skip_idx = 1 + rng.gen_range(exon_window.len() - 2);
1113            let skipped: Vec<Exon> = exon_window
1114                .iter()
1115                .enumerate()
1116                .filter_map(|(idx, exon)| (idx != skip_idx).then_some(exon.clone()))
1117                .collect();
1118            let skipped_seq = isoform_sequence(reference, &tx.chrom, tx.strand, &skipped)?;
1119            if skipped_seq.len() >= min_len {
1120                isoforms.push(SimIsoform {
1121                    isoform_id: "isoform2".to_string(),
1122                    exon_chain: skipped,
1123                    seq: skipped_seq,
1124                    read_pair_count: 0,
1125                    bsj_read_pair_count: 0,
1126                });
1127                switching_candidate_count += 1;
1128            }
1129        }
1130
1131        circs.push(SimCirc {
1132            circ_id,
1133            chrom: tx.chrom.clone(),
1134            start,
1135            end,
1136            strand: tx.strand,
1137            gene_id: tx.gene_id.clone(),
1138            transcript_id: tx.transcript_id.clone(),
1139            isoforms,
1140            sampled_circ_coverage,
1141            read_pair_count,
1142            bsj_read_pair_count: 0,
1143            bsj_read_count: 0,
1144        });
1145    }
1146    Ok(circs)
1147}
1148
1149/// Chooses an isoform while keeping low-depth truth tables inspectable.
1150///
1151/// Single-sample simulation preserves the original uniform behavior. Multi-sample
1152/// simulation passes explicit usage weights, and this helper first emits one
1153/// read for every active isoform so the truth table exposes minor isoforms
1154/// before switching to weighted random sampling.
1155fn choose_isoform_for_pair(
1156    circ: &SimCirc,
1157    usage: Option<&SampleCircUsage>,
1158    pair_idx: usize,
1159    rng: &mut Lcg64,
1160) -> usize {
1161    let Some(usage) = usage else {
1162        return if pair_idx < circ.isoforms.len() {
1163            pair_idx
1164        } else {
1165            rng.gen_range(circ.isoforms.len())
1166        };
1167    };
1168    let active: Vec<usize> = usage
1169        .weights
1170        .iter()
1171        .enumerate()
1172        .filter_map(|(idx, &weight)| (weight > 0.0).then_some(idx))
1173        .collect();
1174    if let Some(&idx) = active.get(pair_idx) {
1175        return idx;
1176    }
1177    let total: f64 = usage.weights.iter().sum();
1178    if total <= 0.0 {
1179        return 0;
1180    }
1181    let mut draw = rng.gen_f64() * total;
1182    for (idx, &weight) in usage.weights.iter().enumerate() {
1183        if weight <= 0.0 {
1184            continue;
1185        }
1186        if draw < weight {
1187            return idx;
1188        }
1189        draw -= weight;
1190    }
1191    usage.weights.len().saturating_sub(1)
1192}
1193
1194/// Emits ordinary paired-end reads from circular isoforms and records read truth.
1195fn write_circular_reads(
1196    circs: &mut [SimCirc],
1197    usage_profile: Option<&SampleUsageProfile>,
1198    args: &SimulateArgs,
1199    rng: &mut Lcg64,
1200    r1_writer: &mut BufWriter<File>,
1201    r2_writer: &mut BufWriter<File>,
1202    read_truth: &mut BufWriter<File>,
1203    next_read_index: &mut usize,
1204) -> Result<()> {
1205    let qual = "I".repeat(args.read_len);
1206    for (circ_idx, circ) in circs.iter_mut().enumerate() {
1207        let circ_usage = usage_profile.and_then(|profile| profile.by_circ.get(circ_idx));
1208        for pair_idx in 0..circ.read_pair_count {
1209            let iso_idx = choose_isoform_for_pair(circ, circ_usage, pair_idx, rng);
1210            let iso = &mut circ.isoforms[iso_idx];
1211            let seq_len = iso.seq.len();
1212            let source_map = source_map_for_exons(&iso.exon_chain, circ.strand);
1213            let insert_len = sample_circular_insert_len(seq_len, args, rng);
1214            let r1_start = rng.gen_range(seq_len);
1215            let r2_start = (r1_start + insert_len.saturating_sub(args.read_len)) % seq_len;
1216            let r1_template = circular_slice(&iso.seq, r1_start, args.read_len);
1217            let r2_template = circular_slice(&iso.seq, r2_start, args.read_len);
1218            let r1_seq = mutate_read(&r1_template, args.error_rate, rng);
1219            let r2_seq = mutate_read(&revcomp(&r2_template), args.error_rate, rng);
1220            let r1_segments =
1221                format_segments(&source_map, circ.strand, r1_start, args.read_len, true);
1222            let r2_segments =
1223                format_segments(&source_map, circ.strand, r2_start, args.read_len, true);
1224            let is_bsj = r1_segments.is_bsj || r2_segments.is_bsj;
1225            let pair_crosses_boundary = crosses_boundary(r1_start, insert_len, seq_len);
1226            // Simulator truth is restricted to read-level observable evidence.
1227            // A read crossing the BSJ is already `bsj`; there is no separate
1228            // truth-side `backward` bucket. Non-BSJ circular-origin fragments
1229            // become `outward` only when their pair orientation is observable.
1230            let is_outward = !is_bsj
1231                && pair_crosses_boundary
1232                && is_outward_facing_truth(
1233                    &source_map,
1234                    circ.strand,
1235                    r1_start,
1236                    r2_start,
1237                    args.read_len,
1238                );
1239            let is_circular = is_bsj || is_outward;
1240            let truth_type = if is_bsj {
1241                "bsj"
1242            } else if is_outward {
1243                "outward"
1244            } else {
1245                "forward"
1246            };
1247            let read_id = format!("sim:{}", *next_read_index);
1248            let metadata = format!(
1249                "read_kind=circ circ_id={} isoform_id={} insert_len={}",
1250                circ.circ_id, iso.isoform_id, insert_len
1251            );
1252            write_fastq(
1253                r1_writer,
1254                &format!("{read_id}/1"),
1255                &r1_seq,
1256                &qual,
1257                &metadata,
1258            )?;
1259            write_fastq(
1260                r2_writer,
1261                &format!("{read_id}/2"),
1262                &r2_seq,
1263                &qual,
1264                &metadata,
1265            )?;
1266            writeln!(
1267                read_truth,
1268                "{read_id}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{truth_type}",
1269                circ.circ_id,
1270                circ.chrom,
1271                circ.start,
1272                circ.end,
1273                circ.strand,
1274                iso.isoform_id,
1275                usize::from(is_circular),
1276                usize::from(is_bsj),
1277                r1_segments.text,
1278                usize::from(r1_segments.is_bsj),
1279                r2_segments.text,
1280                usize::from(r2_segments.is_bsj)
1281            )?;
1282            iso.read_pair_count += 1;
1283            if is_bsj {
1284                iso.bsj_read_pair_count += 1;
1285                circ.bsj_read_pair_count += 1;
1286                circ.bsj_read_count +=
1287                    usize::from(r1_segments.is_bsj) + usize::from(r2_segments.is_bsj);
1288            }
1289            *next_read_index += 1;
1290        }
1291    }
1292    Ok(())
1293}
1294
1295/// Emits linear background reads from the filtered annotation.
1296fn write_linear_reads(
1297    transcripts: &[Transcript],
1298    reference: &HashMap<String, String>,
1299    args: &SimulateArgs,
1300    sampled_linear_coverage: f64,
1301    rng: &mut Lcg64,
1302    r1_writer: &mut BufWriter<File>,
1303    r2_writer: &mut BufWriter<File>,
1304    read_truth: &mut BufWriter<File>,
1305    next_read_index: &mut usize,
1306) -> Result<()> {
1307    let target_pairs = linear_pair_target(transcripts, args, sampled_linear_coverage);
1308    if target_pairs == 0 {
1309        return Ok(());
1310    }
1311    let usable: Vec<&Transcript> = transcripts
1312        .iter()
1313        .filter(|tx| tx.exons.len() >= 2)
1314        .collect();
1315    if usable.is_empty() {
1316        bail!("no usable transcripts for linear read simulation");
1317    }
1318    let qual = "I".repeat(args.read_len);
1319    let mut emitted = 0usize;
1320    let mut attempts = 0usize;
1321    while emitted < target_pairs && attempts < target_pairs * 30 {
1322        attempts += 1;
1323        let tx = usable[rng.gen_range(usable.len())];
1324        let tx_seq = isoform_sequence(reference, &tx.chrom, tx.strand, &tx.exons)?;
1325        let source_map = source_map_for_exons(&tx.exons, tx.strand);
1326        let insert_len = sample_insert_len(args, rng);
1327        if tx_seq.len() <= insert_len || insert_len < args.read_len {
1328            continue;
1329        }
1330        let start = rng.gen_range(tx_seq.len() - insert_len + 1);
1331        let r2_start = start + insert_len - args.read_len;
1332        let Some(r1_template) = linear_slice(&tx_seq, start, args.read_len) else {
1333            continue;
1334        };
1335        let Some(r2_template) = linear_slice(&tx_seq, r2_start, args.read_len) else {
1336            continue;
1337        };
1338        let r1_seq = mutate_read(r1_template, args.error_rate, rng);
1339        let r2_seq = mutate_read(&revcomp(r2_template), args.error_rate, rng);
1340        let r1_segments = format_segments(&source_map, tx.strand, start, args.read_len, false);
1341        let r2_segments = format_segments(&source_map, tx.strand, r2_start, args.read_len, false);
1342        let read_id = format!("sim:{}", *next_read_index);
1343        let metadata = format!(
1344            "read_kind=linear gene_id={} transcript_id={} insert_len={}",
1345            tx.gene_id, tx.transcript_id, insert_len
1346        );
1347        write_fastq(
1348            r1_writer,
1349            &format!("{read_id}/1"),
1350            &r1_seq,
1351            &qual,
1352            &metadata,
1353        )?;
1354        write_fastq(
1355            r2_writer,
1356            &format!("{read_id}/2"),
1357            &r2_seq,
1358            &qual,
1359            &metadata,
1360        )?;
1361        writeln!(
1362            read_truth,
1363            "{read_id}\tNA\t{}\tNA\tNA\t{}\tNA\t0\t0\t{}\t0\t{}\t0\tforward",
1364            tx.chrom, tx.strand, r1_segments.text, r2_segments.text
1365        )?;
1366        *next_read_index += 1;
1367        emitted += 1;
1368    }
1369    if emitted < target_pairs {
1370        bail!(
1371            "only emitted {emitted} linear read pairs after {attempts} attempts; try reducing --insert-len or --linear-coverage"
1372        );
1373    }
1374    Ok(())
1375}
1376
1377/// Writes the circRNA/isoform truth table after read counts are known.
1378fn write_isoform_truth(circs: &[SimCirc], prefix: &str) -> Result<()> {
1379    let mut writer = BufWriter::new(File::create(prefixed_path(prefix, ".isoforms.tsv"))?);
1380    writeln!(
1381        writer,
1382        "circ_id\tchrom\tstart\tend\tstrand\tgene_id\ttranscript_id\tcoverage\tread_cnt\tbsj_read_cnt\tisoform_cnt\tisoform_exons\tisoform_len\tisoform_read_cnt\tisoform_bsj_read_cnt"
1383    )?;
1384    for circ in circs {
1385        let isoform_exons = circ
1386            .isoforms
1387            .iter()
1388            .map(|iso| {
1389                format!(
1390                    "{}={}",
1391                    iso.isoform_id,
1392                    exon_chain_string(&iso.exon_chain, circ.strand)
1393                )
1394            })
1395            .collect::<Vec<_>>()
1396            .join(";");
1397        let isoform_len = circ
1398            .isoforms
1399            .iter()
1400            .map(|iso| format!("{}={}", iso.isoform_id, iso.seq.len()))
1401            .collect::<Vec<_>>()
1402            .join(";");
1403        let isoform_read_cnt = circ
1404            .isoforms
1405            .iter()
1406            .map(|iso| format!("{}={}", iso.isoform_id, iso.read_pair_count))
1407            .collect::<Vec<_>>()
1408            .join(";");
1409        let isoform_bsj_read_cnt = circ
1410            .isoforms
1411            .iter()
1412            .map(|iso| format!("{}={}", iso.isoform_id, iso.bsj_read_pair_count))
1413            .collect::<Vec<_>>()
1414            .join(";");
1415        writeln!(
1416            writer,
1417            "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{:.4}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
1418            circ.circ_id,
1419            circ.chrom,
1420            circ.start,
1421            circ.end,
1422            circ.strand,
1423            circ.gene_id,
1424            circ.transcript_id,
1425            circ.sampled_circ_coverage,
1426            circ.read_pair_count,
1427            circ.bsj_read_pair_count,
1428            circ.isoforms.len(),
1429            isoform_exons,
1430            isoform_len,
1431            isoform_read_cnt,
1432            isoform_bsj_read_cnt
1433        )?;
1434    }
1435    Ok(())
1436}
1437
1438/// Builds sample-level isoform usage weights for matched multi-sample simulation.
1439///
1440/// Two-sample mode keeps all structures fixed and flips the major isoform for
1441/// the first `switching_event` two-isoform circRNAs. The switching set is based
1442/// on stable circRNA order so reruns with the same seed remain reproducible.
1443fn build_sample_usage_profiles(
1444    circs: &[SimCirc],
1445    prefixes: &[String],
1446    switching_event: usize,
1447) -> Vec<SampleUsageProfile> {
1448    let switching_ids: BTreeSet<String> = circs
1449        .iter()
1450        .filter(|circ| circ.isoforms.len() >= 2)
1451        .take(switching_event)
1452        .map(|circ| circ.circ_id.clone())
1453        .collect();
1454
1455    prefixes
1456        .iter()
1457        .enumerate()
1458        .map(|(sample_idx, prefix)| {
1459            let by_circ = circs
1460                .iter()
1461                .map(|circ| SampleCircUsage {
1462                    weights: sample_usage_weights(
1463                        circ,
1464                        switching_ids.contains(&circ.circ_id),
1465                        sample_idx,
1466                    ),
1467                })
1468                .collect();
1469            SampleUsageProfile {
1470                prefix: prefix.clone(),
1471                by_circ,
1472            }
1473        })
1474        .collect()
1475}
1476
1477/// Returns per-isoform usage weights for one circRNA in one sample.
1478fn sample_usage_weights(circ: &SimCirc, switching: bool, sample_idx: usize) -> Vec<f64> {
1479    if circ.isoforms.len() <= 1 {
1480        return vec![1.0];
1481    }
1482    let mut weights = vec![0.0; circ.isoforms.len()];
1483    if switching {
1484        let (first, second) = if sample_idx == 0 {
1485            (SWITCHING_HIGH_USAGE, SWITCHING_LOW_USAGE)
1486        } else {
1487            (SWITCHING_LOW_USAGE, SWITCHING_HIGH_USAGE)
1488        };
1489        weights[0] = first;
1490        weights[1] = second;
1491        return weights;
1492    }
1493    weights[0] = NON_SWITCHING_MAJOR_USAGE;
1494    let minor = (1.0 - NON_SWITCHING_MAJOR_USAGE) / (circ.isoforms.len() - 1) as f64;
1495    for weight in weights.iter_mut().skip(1) {
1496        *weight = minor;
1497    }
1498    weights
1499}
1500
1501/// Writes one usage truth table per simulated sample.
1502///
1503/// Switching events are intentionally not materialized as a separate truth file.
1504/// Downstream evaluation should compare these per-sample usage tables and infer
1505/// major isoform switching from the observed usage values.
1506fn write_sample_usage_truths(
1507    sample_circs: &[Vec<SimCirc>],
1508    profiles: &[SampleUsageProfile],
1509) -> Result<()> {
1510    for (sample, profile) in sample_circs.iter().zip(profiles) {
1511        let mut writer =
1512            BufWriter::new(File::create(prefixed_path(&profile.prefix, ".usage.tsv"))?);
1513        writeln!(
1514            writer,
1515            "circ_id\tchrom\tstart\tend\tstrand\tisoform_id\tusage\tread_cnt\tbsj_read_cnt"
1516        )?;
1517        for circ in sample {
1518            let total = circ.read_pair_count.max(1) as f64;
1519            for iso in &circ.isoforms {
1520                let usage = iso.read_pair_count as f64 / total;
1521                writeln!(
1522                    writer,
1523                    "{}\t{}\t{}\t{}\t{}\t{}\t{:.6}\t{}\t{}",
1524                    circ.circ_id,
1525                    circ.chrom,
1526                    circ.start,
1527                    circ.end,
1528                    circ.strand,
1529                    iso.isoform_id,
1530                    usage,
1531                    iso.read_pair_count,
1532                    iso.bsj_read_pair_count
1533                )?;
1534            }
1535        }
1536        writer.flush()?;
1537    }
1538    Ok(())
1539}
1540
1541/// Ensures the user arguments describe a feasible simulation.
1542fn validate_args(args: &SimulateArgs) -> Result<()> {
1543    if args.out_prefixes.is_empty() {
1544        bail!("at least one -o/--out prefix is required");
1545    }
1546    if args.out_prefixes.len() > 2 {
1547        bail!("multi-sample simulator currently supports one or two -o/--out prefixes");
1548    }
1549    if args.switching_event > 0 && args.out_prefixes.len() < 2 {
1550        bail!("--switching-event requires two -o/--out prefixes");
1551    }
1552    let mut seen_prefixes = HashSet::new();
1553    for prefix in &args.out_prefixes {
1554        if prefix.is_empty() {
1555            bail!("output prefixes must not be empty");
1556        }
1557        if !seen_prefixes.insert(prefix) {
1558            bail!("duplicate output prefix: {prefix}");
1559        }
1560    }
1561    if args.circ_count == 0 {
1562        bail!("--circ-count must be > 0");
1563    }
1564    if args.read_len == 0 {
1565        bail!("--read-len must be > 0");
1566    }
1567    if args.insert_len < args.read_len {
1568        bail!("--insert-len must be >= --read-len");
1569    }
1570    if args.insert_len_minor < args.read_len {
1571        bail!("--insert-len-minor must be >= --read-len");
1572    }
1573    if args.insert_sd < 0.0 || args.insert_sd_minor < 0.0 {
1574        bail!("insert standard deviations must be >= 0");
1575    }
1576    if !(0.0..=1.0).contains(&args.minor_insert_fraction) {
1577        bail!("--minor-insert-fraction must be in [0, 1]");
1578    }
1579    if args.circ_coverage < 0.0 || args.linear_coverage < 0.0 {
1580        bail!("coverage values must be >= 0");
1581    }
1582    if args.scale < 0.0 {
1583        bail!("--scale must be >= 0");
1584    }
1585    if !(0.0..=1.0).contains(&args.error_rate) {
1586        bail!("--error-rate must be in [0, 1]");
1587    }
1588    if !(0.0..=1.0).contains(&args.exon_exclusive_rate) {
1589        bail!("--exon-exclusive-rate must be in [0, 1]");
1590    }
1591    Ok(())
1592}
1593
1594/// Loads annotation/reference data, simulates reads, and writes all formal outputs.
1595///
1596/// The simulator is run through the dedicated `ciri-simulator` binary in normal
1597/// use, but this function keeps the generator callable from module tests without
1598/// shelling out to a subprocess.
1599pub fn run(args: SimulateArgs) -> Result<SimulationSummary> {
1600    validate_args(&args)?;
1601    let fastq_compressor = select_fastq_compressor()?;
1602    for prefix in &args.out_prefixes {
1603        if let Some(parent) = Path::new(prefix).parent() {
1604            if !parent.as_os_str().is_empty() {
1605                create_dir_all(parent)?;
1606            }
1607        }
1608    }
1609
1610    let mut rng = Lcg64::new(args.seed);
1611    let mut fasta = FastaReader::new();
1612    fasta.read_fasta(&args.ref_fasta)?;
1613    let annotation = read_annotation_model(
1614        &args.gtf,
1615        args.chrom.as_deref(),
1616        args.exon_exclusive_rate,
1617        &mut rng,
1618    )?;
1619    if annotation.transcripts.is_empty() {
1620        bail!("no exon-bearing transcripts found for the requested filter");
1621    }
1622    if annotation.linear_transcripts.is_empty() {
1623        bail!("no usable linear transcripts remain after circRNA-exclusive exon filtering");
1624    }
1625
1626    let circs = build_circs(
1627        &annotation.transcripts,
1628        &fasta.chr_tcga_map,
1629        &args,
1630        &mut rng,
1631    )?;
1632    if circs.is_empty() {
1633        bail!("no circRNAs could be built; try lowering --read-len or --circ-count");
1634    }
1635    let usage_profiles = if args.out_prefixes.len() > 1 {
1636        build_sample_usage_profiles(&circs, &args.out_prefixes, args.switching_event)
1637    } else {
1638        Vec::new()
1639    };
1640
1641    let mut sample_circs = Vec::with_capacity(args.out_prefixes.len());
1642    let mut total_read_pairs = 0usize;
1643    let mut circ_read_pairs = 0usize;
1644    let mut linear_read_pairs = 0usize;
1645    let mut bsj_read_pairs = 0usize;
1646    let mut bsj_reads = 0usize;
1647    for (sample_idx, prefix) in args.out_prefixes.iter().enumerate() {
1648        write_linear_annotation(&annotation, prefix)?;
1649        let mut circs_for_sample = circs.clone();
1650        let profile = usage_profiles.get(sample_idx);
1651        let r1_tmp_path = prefixed_path(prefix, "_1.fq.tmp");
1652        let r2_tmp_path = prefixed_path(prefix, "_2.fq.tmp");
1653        let r1_gz_path = prefixed_path(prefix, "_1.fq.gz");
1654        let r2_gz_path = prefixed_path(prefix, "_2.fq.gz");
1655        let mut r1_writer = BufWriter::new(File::create(&r1_tmp_path)?);
1656        let mut r2_writer = BufWriter::new(File::create(&r2_tmp_path)?);
1657        let mut read_truth = BufWriter::new(File::create(prefixed_path(prefix, ".reads.tsv"))?);
1658        writeln!(
1659            read_truth,
1660            "read_id\tcirc_id\tchrom\tstart\tend\tstrand\tisoform_id\tis_circular\tis_bsj\tr1_segments\tr1_is_bsj\tr2_segments\tr2_is_bsj\ttype"
1661        )?;
1662
1663        let mut next_read_index = 1usize;
1664        write_circular_reads(
1665            &mut circs_for_sample,
1666            profile,
1667            &args,
1668            &mut rng,
1669            &mut r1_writer,
1670            &mut r2_writer,
1671            &mut read_truth,
1672            &mut next_read_index,
1673        )?;
1674        let sampled_linear_coverage = sample_coverage(args.linear_coverage, args.scale, &mut rng);
1675        write_linear_reads(
1676            &annotation.linear_transcripts,
1677            &fasta.chr_tcga_map,
1678            &args,
1679            sampled_linear_coverage,
1680            &mut rng,
1681            &mut r1_writer,
1682            &mut r2_writer,
1683            &mut read_truth,
1684            &mut next_read_index,
1685        )?;
1686        r1_writer.flush()?;
1687        r2_writer.flush()?;
1688        read_truth.flush()?;
1689        drop(r1_writer);
1690        drop(r2_writer);
1691        drop(read_truth);
1692        compress_fastq_to_gz(&r1_tmp_path, &r1_gz_path, fastq_compressor)?;
1693        compress_fastq_to_gz(&r2_tmp_path, &r2_gz_path, fastq_compressor)?;
1694        write_isoform_truth(&circs_for_sample, prefix)?;
1695
1696        let sample_total_read_pairs = next_read_index - 1;
1697        let sample_circ_read_pairs: usize = circs_for_sample
1698            .iter()
1699            .map(|circ| circ.read_pair_count)
1700            .sum();
1701        total_read_pairs += sample_total_read_pairs;
1702        circ_read_pairs += sample_circ_read_pairs;
1703        linear_read_pairs += sample_total_read_pairs.saturating_sub(sample_circ_read_pairs);
1704        bsj_read_pairs += circs_for_sample
1705            .iter()
1706            .map(|circ| circ.bsj_read_pair_count)
1707            .sum::<usize>();
1708        bsj_reads += circs_for_sample
1709            .iter()
1710            .map(|circ| circ.bsj_read_count)
1711            .sum::<usize>();
1712        sample_circs.push(circs_for_sample);
1713    }
1714    if args.out_prefixes.len() > 1 {
1715        write_sample_usage_truths(&sample_circs, &usage_profiles)?;
1716    }
1717
1718    let isoform_count: usize = circs.iter().map(|circ| circ.isoforms.len()).sum();
1719
1720    Ok(SimulationSummary {
1721        sample_count: args.out_prefixes.len(),
1722        circ_count: circs.len(),
1723        isoform_count,
1724        total_read_pairs,
1725        circ_read_pairs,
1726        linear_read_pairs,
1727        bsj_reads,
1728        bsj_read_pairs,
1729    })
1730}
1731
1732#[cfg(test)]
1733mod tests;