Skip to main content

salmon_model/
fld.rs

1//! Fragment-length distribution.
2//!
3//! # What it is and why it matters
4//!
5//! Library preparation shears RNA into fragments whose lengths vary around some
6//! protocol-specific typical size (often ~200-300 bases). Two things depend on
7//! knowing that distribution:
8//!
9//! * **Effective length.** A transcript can only host a fragment that fits
10//!   inside it, so the number of usable start positions depends on how long
11//!   fragments actually are. That is the divisor turning fragment counts into
12//!   abundances.
13//! * **Mapping plausibility.** If a paired mapping implies a 900-base fragment
14//!   in a library whose fragments are 250 bases, that mapping is probably wrong,
15//!   and its weight should reflect that.
16//!
17//! Direct port of salmon's `FragmentLengthDistribution`
18//! (`src/model/FragmentLengthDistribution.cpp`): a log-space histogram seeded
19//! with a Gaussian (or uniform) prior, updated by adding a binomial smoothing
20//! kernel around each observed length.
21//!
22//! **Why a smoothing kernel.** Each observation is spread over neighbouring
23//! lengths rather than dropped into one bin, because a fragment observed at 249
24//! bases is evidence that 248 and 250 are plausible too. Without smoothing the
25//! histogram would be spiky and a length that happened not to be observed would
26//! get probability zero.
27//!
28//! All masses and probabilities are in log space. Updates are lock-free so worker
29//! threads can call [`add_val`] with a shared reference, matching the C++ design.
30//!
31//! [`add_val`]: FragmentLengthDistribution::add_val
32
33use salmon_core::atomic::AtomicF64;
34use salmon_core::math::{log_add, LOG_0, LOG_EPSILON};
35use salmon_core::{LibraryFormat, ReadOrientation, ReadStrandedness, ReadType};
36use statrs::distribution::{Binomial, ContinuousCDF, Discrete, Normal};
37use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
38use std::sync::{Arc, RwLock};
39
40/// Tracks the observed distribution of fragment lengths.
41#[derive(Debug)]
42pub struct FragmentLengthDistribution {
43    /// logged binomial smoothing kernel
44    kernel: Vec<f64>,
45    /// logged observed mass per length bin
46    hist: Vec<AtomicF64>,
47    /// logged total observed mass (including pseudo-counts)
48    tot_mass: AtomicF64,
49    /// logged sum of length*mass, for fast mean computation
50    ///
51    /// Maintained incrementally so the mean is a subtraction rather than a sweep
52    /// over every bin.
53    sum: AtomicF64,
54    /// minimum observed length (bin units)
55    min: AtomicUsize,
56    /// internal bin size
57    bin_size: usize,
58
59    /// cached normalized PMF, valid once [`cache`](Self::cache) is called
60    cached_pmf: Vec<f64>,
61    /// cached CMF
62    cached_cmf: Vec<f64>,
63    have_cache: bool,
64
65    /// Periodically-refreshed snapshot of the (un-normalized) log-PMF used during
66    /// the *online* phase, indexed by raw length. Reading the live `hist`/`tot_mass`
67    /// directly (two separate atomic loads on a concurrently-updated distribution)
68    /// returns slightly different values for the same length across calls, which
69    /// breaks the weight symmetry of exact-duplicate transcripts and is then
70    /// amplified by the VBEM `α<1` prior. Mirroring C++ salmon (`cachedPMF_` +
71    /// `LogCMFCache`), worker threads instead capture an immutable snapshot of this
72    /// once per fragment ([`online_snapshot`](Self::online_snapshot)) so every
73    /// transcript of a given length in that fragment gets an identical value;
74    /// [`refresh_online`](Self::refresh_online) rebuilds it at mini-batch
75    /// boundaries.
76    ///
77    /// This is a real correctness issue, not a micro-optimization: two transcripts
78    /// that are byte-identical must receive byte-identical weights, or the EM will
79    /// split them unevenly for no reason.
80    online_pmf: RwLock<Arc<Vec<f64>>>,
81
82    /// Periodically-refreshed snapshot of the (normalized) log-CMF, the
83    /// cumulative companion to [`online_pmf`](Self::online_pmf). Used for the
84    /// ambiguous (orphan / single-end) fragment-length probability and for the
85    /// `pmf(flen) − cmf(txpLen)` length-conditioning of proper pairs, both of
86    /// which need cumulative mass. Rebuilt alongside `online_pmf` in
87    /// [`refresh_online`](Self::refresh_online); mirrors C++ salmon's
88    /// `LogCMFCache`.
89    online_cmf: RwLock<Arc<Vec<f64>>>,
90}
91
92impl FragmentLengthDistribution {
93    /// Construct a distribution.
94    ///
95    /// * `alpha` – total pseudo-count mass (linear space).
96    /// * `max_val` – maximum representable length.
97    /// * `prior_mu` – Gaussian prior mean; if `<= 0`, a uniform prior is used.
98    /// * `prior_sigma` – Gaussian prior standard deviation.
99    /// * `kernel_n` – binomial kernel trials; must be even (after binning).
100    /// * `kernel_p` – binomial kernel success probability.
101    /// * `bin_size` – internal length binning (use 1 for no binning).
102    ///
103    /// The prior is what the distribution believes before seeing any data; a
104    /// paired-end run quickly overwhelms it with observations, while a single-end
105    /// run (which observes no fragment lengths at all) keeps it.
106    pub fn new(
107        alpha: f64,
108        max_val: usize,
109        prior_mu: f64,
110        prior_sigma: f64,
111        kernel_n: usize,
112        kernel_p: f64,
113        bin_size: usize,
114    ) -> Self {
115        assert!(bin_size >= 1, "bin_size must be >= 1");
116        // Everything below works in *bin* units, so convert once here.
117        let max_val = max_val / bin_size;
118        let kernel_n = kernel_n / bin_size;
119        // An even kernel has a well-defined centre bin to place the observation at.
120        assert!(
121            kernel_n.is_multiple_of(2),
122            "kernel_n must be even after binning"
123        );
124
125        let tot = alpha.ln();
126        let hist: Vec<AtomicF64>;
127        let mut sum = LOG_0;
128        let mut tot_mass;
129
130        if prior_mu > 0.0 {
131            let norm = Normal::new(
132                prior_mu / bin_size as f64,
133                prior_sigma / (bin_size * bin_size) as f64,
134            )
135            .expect("valid normal prior");
136            hist = (0..=max_val).map(|_| AtomicF64::new(LOG_0)).collect();
137            tot_mass = LOG_0;
138            for (i, slot) in hist.iter().enumerate() {
139                // Discretize the continuous Gaussian: the mass of bin `i` is the
140                // area between `i - 0.5` and `i + 0.5`.
141                let norm_mass = norm.cdf(i as f64 + 0.5) - norm.cdf(i as f64 - 0.5);
142                let mass = if norm_mass != 0.0 {
143                    tot + norm_mass.ln()
144                } else {
145                    // Far tail underflowed to exactly zero; use the finite
146                    // "effectively zero" value so later arithmetic stays defined.
147                    LOG_EPSILON
148                };
149                slot.store(mass);
150                sum = log_add(sum, (i as f64).ln() + mass);
151                tot_mass = log_add(tot_mass, mass);
152            }
153        } else {
154            // uniform prior
155            let per = tot - (max_val as f64).ln();
156            hist = (0..=max_val).map(|_| AtomicF64::new(per)).collect();
157            // Length 0 is impossible.
158            hist[0].store(LOG_0);
159            // Closed form for Σ l·mass with a flat mass: mass · n(n+1)/2.
160            let h1 = hist.get(1).map(|a| a.load()).unwrap_or(per);
161            sum = h1 + ((max_val * (max_val + 1)) as f64).ln() - 2.0_f64.ln();
162            tot_mass = tot;
163        }
164
165        // binomial smoothing kernel
166        //
167        // A binomial PMF is a discrete bell curve, so each observation is spread
168        // over its neighbours with the centre weighted most.
169        let binom = Binomial::new(kernel_p, kernel_n as u64).expect("valid binomial kernel");
170        let kernel: Vec<f64> = (0..=kernel_n).map(|i| binom.pmf(i as u64).ln()).collect();
171
172        Self {
173            kernel,
174            hist,
175            tot_mass: AtomicF64::new(tot_mass),
176            sum: AtomicF64::new(sum),
177            // Seeded at the maximum so the first `fetch_min` wins.
178            min: AtomicUsize::new(max_val),
179            bin_size,
180            cached_pmf: Vec::new(),
181            cached_cmf: Vec::new(),
182            have_cache: false,
183            online_pmf: RwLock::new(Arc::new(Vec::new())),
184            online_cmf: RwLock::new(Arc::new(Vec::new())),
185        }
186    }
187
188    /// salmon's default fragment-length distribution: pseudo-count 1.0, max
189    /// length 1000, no Gaussian prior (uniform), kernel `n=4, p=0.5`.
190    ///
191    /// A uniform prior for paired-end data because the observations will supply
192    /// the shape; the prior only has to avoid ruling anything out.
193    pub fn default_for_paired() -> Self {
194        Self::new(1.0, 1000, 0.0, 0.0, 4, 0.5, 1)
195    }
196
197    /// Largest representable raw length.
198    pub fn max_val(&self) -> usize {
199        (self.hist.len() - 1) * self.bin_size
200    }
201
202    /// Smallest observed length; 1 when nothing has been observed (the sentinel
203    /// initial value is the last bin).
204    pub fn min_val(&self) -> usize {
205        let m = self.min.load(Ordering::Relaxed);
206        if m == self.hist.len() - 1 {
207            1
208        } else {
209            m
210        }
211    }
212
213    /// Add `mass` (log space) for an observed fragment of length `len`,
214    /// spreading it over the smoothing kernel. Lock-free; safe to call from
215    /// multiple threads. (Must not race with [`cache`](Self::cache).)
216    pub fn add_val(&self, len: usize, mass: f64) {
217        let mut len = len / self.bin_size;
218        let max_v = self.max_val() / self.bin_size;
219        // An implausibly long fragment saturates rather than being dropped.
220        if len > max_v {
221            len = max_v;
222        }
223        self.min.fetch_min(len, Ordering::Relaxed);
224
225        let half = self.kernel.len() / 2;
226        // offset can go negative conceptually; use isize math then bound-check.
227        // Centring the kernel on `len` means the observation contributes most to
228        // its own bin and progressively less to its neighbours.
229        let start = len as isize - half as isize;
230        for (offset, &k) in (start..).zip(self.kernel.iter()) {
231            if offset > 0 && (offset as usize) < self.hist.len() {
232                let o = offset as usize;
233                // Adding logs multiplies the observation's mass by the kernel
234                // weight.
235                let k_mass = mass + k;
236                self.hist[o].log_add_assign(k_mass);
237                self.sum.log_add_assign((o as f64).ln() + k_mass);
238                self.tot_mass.log_add_assign(k_mass);
239            }
240        }
241    }
242
243    /// Logged probability of observing a fragment of length `len`.
244    pub fn pmf(&self, len: usize) -> f64 {
245        // Once frozen, this is a direct array read with no normalization work.
246        if self.have_cache {
247            return *self
248                .cached_pmf
249                .get(len)
250                .unwrap_or_else(|| self.cached_pmf.last().unwrap());
251        }
252        let mut l = len / self.bin_size;
253        let max_v = self.max_val() / self.bin_size;
254        if l > max_v {
255            l = max_v;
256        }
257        // Normalizing in log space is a subtraction.
258        self.hist[l].load() - self.tot_mass.load()
259    }
260
261    /// Rebuild the online log-PMF snapshot from the current histogram (one pass
262    /// over the length bins, with a single `tot_mass` read so the snapshot is
263    /// internally consistent). Call at mini-batch boundaries during the online
264    /// phase; no-op once the final [`cache`](Self::cache) has been taken. Cheap
265    /// relative to mapping a batch, and decouples per-fragment reads from the
266    /// concurrent `add_val` writes so identical lengths read identical values.
267    pub fn refresh_online(&self) {
268        if self.have_cache {
269            return;
270        }
271        let max_raw = self.max_val();
272        let max_v = max_raw / self.bin_size;
273        // One read, reused for every bin: mixing two reads of a concurrently
274        // updated total is exactly the inconsistency this snapshot exists to avoid.
275        let tot = self.tot_mass.load();
276        // Per-bin cumulative mass (matches `cmf()`), so the snapshot CMF at raw
277        // index `raw` equals `cmf(raw)`. Built first, then both the PMF and CMF
278        // snapshots are expanded over raw indices from the same `tot` read so
279        // they are mutually consistent.
280        let mut bin_cum = Vec::with_capacity(max_v + 1);
281        let mut cum = LOG_0;
282        for b in 0..=max_v {
283            cum = log_add(cum, self.hist[b].load() - tot);
284            bin_cum.push(cum);
285        }
286        // Expand from bin indices to raw lengths, so lookups need no division.
287        let mut v = Vec::with_capacity(max_raw + 1);
288        let mut c = Vec::with_capacity(max_raw + 1);
289        for raw in 0..=max_raw {
290            let l = (raw / self.bin_size).min(max_v);
291            v.push(self.hist[l].load() - tot);
292            c.push(bin_cum[l]);
293        }
294        // Publishing a fresh `Arc` rather than mutating in place means readers
295        // holding the old snapshot keep a consistent view.
296        *self.online_pmf.write().unwrap() = Arc::new(v);
297        *self.online_cmf.write().unwrap() = Arc::new(c);
298    }
299
300    /// Cheap (one `Arc` clone) immutable handle to the current online log-PMF
301    /// snapshot. Capture once per fragment and index by raw length: every
302    /// transcript of a given length then reads an identical value even if another
303    /// thread refreshes the shared snapshot meanwhile. Empty until the first
304    /// [`refresh_online`](Self::refresh_online) (the pre-burn-in window, where this
305    /// term is not folded into the eq-class weight anyway).
306    pub fn online_snapshot(&self) -> Arc<Vec<f64>> {
307        self.online_pmf.read().unwrap().clone()
308    }
309
310    /// Cheap (one `Arc` clone) immutable handle to the current online log-CMF
311    /// snapshot, the cumulative companion to [`online_snapshot`](Self::online_snapshot).
312    /// Capture once per fragment for the ambiguous (orphan / single-end)
313    /// fragment-length probability and the proper-pair length-conditioning.
314    /// Empty until the first [`refresh_online`](Self::refresh_online).
315    pub fn online_cmf_snapshot(&self) -> Arc<Vec<f64>> {
316        self.online_cmf.read().unwrap().clone()
317    }
318
319    /// Logged cumulative mass up to and including `len`.
320    ///
321    /// The uncached path re-sums from zero each call, which is why the online
322    /// phase uses the precomputed snapshot instead.
323    pub fn cmf(&self, len: usize) -> f64 {
324        if self.have_cache {
325            return *self
326                .cached_cmf
327                .get(len)
328                .unwrap_or_else(|| self.cached_cmf.last().unwrap());
329        }
330        let mut l = len / self.bin_size;
331        let max_v = self.max_val() / self.bin_size;
332        if l > max_v {
333            l = max_v;
334        }
335        let mut cum = LOG_0;
336        for i in 0..=l {
337            cum = log_add(cum, self.hist[i].load());
338        }
339        cum - self.tot_mass.load()
340    }
341
342    /// Total observed mass (log space).
343    pub fn tot_mass(&self) -> f64 {
344        self.tot_mass.load()
345    }
346
347    /// Mean observed length.
348    ///
349    /// `Σ l·mass / Σ mass`, which in log space is one subtraction of the two
350    /// running accumulators.
351    pub fn mean(&self) -> f64 {
352        (self.sum.load() - self.tot_mass.load()).exp()
353    }
354
355    /// Standard deviation of the observed length distribution, computed from the
356    /// cached normalized PMF (call after [`cache`](Self::cache)).
357    ///
358    /// Two passes — mean, then squared deviations — rather than the one-pass
359    /// `E[X²] − E[X]²` form, which loses precision when the two terms are close.
360    pub fn sd(&self) -> f64 {
361        let lp = self.log_pmf();
362        if lp.is_empty() {
363            return 0.0;
364        }
365        let mut mean = 0.0;
366        for (l, &p) in lp.iter().enumerate() {
367            mean += (l as f64) * p.exp();
368        }
369        let mut var = 0.0;
370        for (l, &p) in lp.iter().enumerate() {
371            let d = l as f64 - mean;
372            var += d * d * p.exp();
373        }
374        // `.max(0.0)` guards a tiny negative variance from rounding.
375        var.max(0.0).sqrt()
376    }
377
378    /// Freeze the distribution and precompute normalized PMF/CMF for fast,
379    /// allocation-free lookup. Call once after updates have stopped.
380    ///
381    /// After this, every lookup is an array index; before it, each one does
382    /// atomic loads and (for the CMF) a running sum.
383    pub fn cache(&mut self) {
384        if self.have_cache {
385            return;
386        }
387        let max_v = self.max_val();
388        // normalized PMF over [0, max_v]
389        let mut pmf = Vec::with_capacity(max_v + 1);
390        let mut tot = LOG_0;
391        for i in 0..=max_v {
392            let p = self.pmf(i);
393            pmf.push(p);
394            tot = log_add(tot, p);
395        }
396        // Renormalize: binning and clamping mean the per-bin values need not sum
397        // to exactly 1 on their own.
398        for p in &mut pmf {
399            *p -= tot;
400        }
401        // CMF from the normalized PMF
402        let mut cmf = Vec::with_capacity(pmf.len());
403        let mut cum = LOG_0;
404        for &p in &pmf {
405            cum = log_add(cum, p);
406            cmf.push(cum);
407        }
408        self.cached_pmf = pmf;
409        self.cached_cmf = cmf;
410        self.have_cache = true;
411    }
412
413    /// Reconstruct a *cached* distribution directly from a (log-space) PMF,
414    /// e.g. one serialized into a RAD header during a previous run. `log_pmf` is
415    /// indexed by raw length over `[0, log_pmf.len())`. The CMF, conditional
416    /// means, mean and sd are all re-derived from it via [`cache`](Self::cache),
417    /// so a reconstructed distribution is interchangeable with the original for
418    /// every read-side use. The masses need not be pre-normalized — `cache`
419    /// normalizes them — but a normalized PMF round-trips exactly.
420    ///
421    /// This is what makes a RAD requant reproduce the original run: the exact
422    /// distribution is restored rather than re-estimated.
423    pub fn from_log_pmf(log_pmf: &[f64]) -> Self {
424        let max_val = log_pmf.len().saturating_sub(1);
425        let mut d = Self::new(1.0, max_val, 0.0, 1.0, 4, 0.5, 1);
426        // Replace the prior histogram with the supplied masses and recompute the
427        // aggregate statistics (so `mean`/`sd` are consistent), then cache.
428        let mut tot = LOG_0;
429        let mut sm = LOG_0;
430        for (i, &p) in log_pmf.iter().enumerate() {
431            d.hist[i].store(p);
432            tot = log_add(tot, p);
433            // Skip `i == 0`: `ln(0)` is -inf and length zero carries no mass.
434            if i > 0 {
435                sm = log_add(sm, (i as f64).ln() + p);
436            }
437        }
438        d.tot_mass.store(tot);
439        d.sum.store(sm);
440        d.min.store(0, Ordering::Relaxed);
441        d.cache();
442        d
443    }
444
445    /// The cached, normalized log-PMF over `[0, max_val]`. Requires [`cache`](Self::cache).
446    pub fn log_pmf(&self) -> &[f64] {
447        debug_assert!(self.have_cache, "call cache() before log_pmf()");
448        &self.cached_pmf
449    }
450
451    /// Cumulative conditional means `E[L | L ≤ i]` over `[0, max_val]`, i.e.
452    /// salmon's `correctionFactorsFromMass` (`DistributionUtils.cpp`):
453    /// `cm[i] = (Σ_{l≤i} l·pmf[l]) / (Σ_{l≤i} pmf[l])`.
454    ///
455    /// Read `cm[i]` as: given a transcript of length `i`, how long is a typical
456    /// fragment it can host? Subtracting that from the reference length is the
457    /// smoothed effective length — the transcript loses the tail where no fragment
458    /// of typical length could start.
459    ///
460    /// These are the per-length correction factors `computeSmoothedEffectiveLengths`
461    /// subtracts from the reference length to get the base effective length. The
462    /// ratio is invariant to the PMF normalization, so the cached (normalized) PMF
463    /// gives the same values as salmon's `100·exp(logPMF)` mass. Requires
464    /// [`cache`](Self::cache).
465    pub fn conditional_means(&self) -> Vec<f64> {
466        debug_assert!(self.have_cache, "call cache() before conditional_means()");
467        let n = self.cached_pmf.len();
468        let mut cms = vec![0.0f64; n];
469        // Running numerator and denominator, so the whole vector is one pass.
470        let mut vals = 0.0; // Σ l·pmf[l]
471        let mut mult = 0.0; // Σ pmf[l]
472        for i in 0..n {
473            let p = self.cached_pmf[i].exp();
474            vals += (i as f64) * p;
475            mult += p;
476            cms[i] = if mult > 0.0 { vals / mult } else { 0.0 };
477        }
478        cms
479    }
480}
481
482/// Index a length into a (log) CMF snapshot, clamping out-of-range lengths to
483/// the last bin (which holds the total mass). Returns [`LOG_0`] for an empty
484/// snapshot.
485#[inline]
486fn cmf_at(cmf: &[f64], len: i32) -> f64 {
487    if cmf.is_empty() {
488        return LOG_0;
489    }
490    let i = (len.max(0) as usize).min(cmf.len() - 1);
491    cmf[i]
492}
493
494/// Logged ambiguous-fragment-length probability for an orphan / single-end
495/// read, given a (log) CMF snapshot. Direct port of C++ salmon's
496/// `LogCMFCache::getAmbigFragLengthProb` (`DistributionUtils.cpp`).
497///
498/// **The idea.** With only one end observed, the fragment length is unknown — but
499/// not unconstrained. The mapped mate bounds the maximum possible fragment
500/// length: a forward read at `pos` can extend downstream to the transcript 3' end
501/// (`txp_len − pos`); a reverse read's outer (5') end sits at `pos + read_len`,
502/// bounding the upstream extent toward the 5' end. So instead of a point
503/// probability we take the FLD mass up to that bound — "the fragment was at most
504/// this long" — which is exactly a CMF lookup.
505///
506/// The weight is then *conditioned* on the mass up to the full transcript length
507/// — i.e. `cmf(maxFragLen) − cmf(txpLen)` — so orphan weights sit on the same
508/// length-conditioned scale as proper pairs and the two are comparable. Returns
509/// [`LOG_EPSILON`] when the transcript admits no representable fragment mass, and
510/// `LOG_1` (= 0) when no snapshot is available yet (pre-burn-in), leaving the
511/// weight unmodelled.
512pub fn ambig_frag_log_prob(cmf: &[f64], fwd: bool, pos: i32, read_len: i32, txp_len: i32) -> f64 {
513    if cmf.is_empty() {
514        return 0.0; // LOG_1: no model yet
515    }
516    let stxp = txp_len.max(0);
517    // How much room the observed mate leaves for the unobserved one.
518    let max_frag_len = if fwd {
519        stxp - pos.clamp(0, stxp)
520    } else {
521        (pos + read_len).clamp(0, stxp)
522    };
523    let ref_cm = cmf_at(cmf, stxp);
524    if ref_cm <= LOG_0 {
525        return LOG_EPSILON;
526    }
527    // Division in log space: the conditional probability given that the fragment
528    // fits in the transcript at all.
529    cmf_at(cmf, max_frag_len) - ref_cm
530}
531
532/// salmon's base effective length (`computeSmoothedEffectiveLengths`):
533/// `effLen = refLen − E[L | L ≤ refLen]`, clamped back to `refLen` if it would
534/// fall below 1. `cond_means` is [`FragmentLengthDistribution::conditional_means`].
535///
536/// This replaces the truncated-PMF `Σ pmf(l)·(refLen−l+1)` estimate (which falls
537/// back to the raw `refLen` for any transcript shorter than the FLD mean), matching
538/// salmon's behaviour exactly. The difference matters for short transcripts, where
539/// the truncated sum has almost no mass to work with and silently degrades to no
540/// correction at all.
541pub fn smoothed_effective_length(cond_means: &[f64], ref_len: usize) -> f64 {
542    if cond_means.is_empty() {
543        return ref_len as f64;
544    }
545    let max_len = cond_means.len();
546    // A transcript longer than the FLD's support uses the last conditional mean.
547    let cf = if ref_len >= max_len {
548        cond_means[max_len - 1]
549    } else {
550        cond_means[ref_len]
551    };
552    let eff = ref_len as f64 - cf;
553    // A non-positive effective length would be a nonsensical divisor.
554    if eff < 1.0 {
555        ref_len as f64
556    } else {
557        eff
558    }
559}
560
561/// Order-independent accumulator for *deriving* a fragment-length distribution
562/// (and library format) from uniquely-mapped proper pairs.
563///
564/// Unlike [`FragmentLengthDistribution`] — which accumulates in **log space** and
565/// is required for the online phase (it folds in per-fragment forgetting mass and
566/// is read per fragment) — this stores plain **integer counts** per length,
567/// bucketed by orientation. Integer increments are commutative, so the tallies
568/// are independent of thread / chunk order; the FLD is then built **once**,
569/// deterministically (fixed length order), in [`finish`](Self::finish). Keeping
570/// it a separate type means the online FLD's float/log-space accumulation is
571/// never disturbed, and no runtime dispatch is needed.
572///
573/// This is what `--deterministic` uses: same input, byte-identical distribution,
574/// whatever the thread count.
575#[derive(Debug)]
576pub struct DiscreteFld {
577    /// per-length counts for opposite-strand (inward/outward) proper pairs
578    opp: Vec<AtomicU64>,
579    /// per-length counts for same-strand proper pairs
580    ///
581    /// Kept separate because a library has one true geometry; mixing the two
582    /// would blend a real distribution with mis-mapped noise.
583    same: Vec<AtomicU64>,
584    n_opp: AtomicU64,
585    n_same: AtomicU64,
586    /// observed-format tally (indexed by [`LibraryFormat::format_id`]) for
587    /// order-independent `-l A` auto-detection
588    fmt_counts: [AtomicU64; 12],
589    max_len: usize,
590}
591
592impl DiscreteFld {
593    /// Create an accumulator covering raw fragment lengths `[0, fld_max]`.
594    pub fn new(fld_max: usize) -> Self {
595        Self {
596            opp: (0..=fld_max).map(|_| AtomicU64::new(0)).collect(),
597            same: (0..=fld_max).map(|_| AtomicU64::new(0)).collect(),
598            n_opp: AtomicU64::new(0),
599            n_same: AtomicU64::new(0),
600            fmt_counts: std::array::from_fn(|_| AtomicU64::new(0)),
601            max_len: fld_max,
602        }
603    }
604
605    /// Record one uniquely-mapped proper pair from its mate strands: fragment
606    /// length, orientation bucket, and observed format (derived from `is_fw` /
607    /// `mate_fw`, mirroring the RAD reader's `rad_frag_format` so the mapping pass
608    /// and the RAD-derive path agree). Works in sketch mode, where no precomputed
609    /// `LibraryFormat` is available. Thread-safe and order-independent.
610    ///
611    /// *Uniquely* mapped, because an ambiguous pair's implied fragment length
612    /// depends on which transcript it really came from — unknown at this stage.
613    pub fn add(&self, len: usize, is_fw: bool, mate_fw: bool) {
614        let l = len.min(self.max_len);
615        // opposite-strand (inward/outward) vs same-strand, exactly as the RAD
616        // reader classifies placements.
617        let (orientation, strandedness) = if is_fw != mate_fw {
618            let s = if is_fw {
619                ReadStrandedness::SA
620            } else {
621                ReadStrandedness::AS
622            };
623            (ReadOrientation::Toward, s)
624        } else {
625            let s = if is_fw {
626                ReadStrandedness::S
627            } else {
628                ReadStrandedness::A
629            };
630            (ReadOrientation::Same, s)
631        };
632        if orientation == ReadOrientation::Same {
633            self.same[l].fetch_add(1, Ordering::Relaxed);
634            self.n_same.fetch_add(1, Ordering::Relaxed);
635        } else {
636            self.opp[l].fetch_add(1, Ordering::Relaxed);
637            self.n_opp.fetch_add(1, Ordering::Relaxed);
638        }
639        let fmt = LibraryFormat::new(ReadType::PairedEnd, orientation, strandedness);
640        self.fmt_counts[fmt.format_id() as usize].fetch_add(1, Ordering::Relaxed);
641    }
642
643    /// Total unique proper pairs seen across both orientation buckets.
644    pub fn count(&self) -> u64 {
645        self.n_opp.load(Ordering::Relaxed) + self.n_same.load(Ordering::Relaxed)
646    }
647
648    /// Build the FLD from the majority-orientation bucket (deterministically, in
649    /// fixed length order) and infer the library format from the format tally.
650    /// `fld_mean`/`fld_sd` seed the prior. Returns the cached FLD and the detected
651    /// format (`None` when no proper pairs were seen).
652    pub fn finish(
653        &self,
654        fld_mean: f64,
655        fld_sd: f64,
656    ) -> (FragmentLengthDistribution, Option<LibraryFormat>) {
657        let n_opp = self.n_opp.load(Ordering::Relaxed);
658        let n_same = self.n_same.load(Ordering::Relaxed);
659        // The library's real geometry is the majority one; the minority bucket is
660        // mis-mapping noise and is discarded.
661        let chosen = if n_opp >= n_same {
662            &self.opp
663        } else {
664            &self.same
665        };
666        let mut fld =
667            FragmentLengthDistribution::new(1.0, self.max_len, fld_mean, fld_sd, 4, 0.5, 1);
668        // `add_val(len, ln count)` equals `count` unit `add_val(len, 0)` calls in
669        // log space, so this reproduces the per-fragment FLD — but deterministically.
670        // Adding `ln(count)` once is exact, whereas `count` separate additions
671        // would accumulate rounding in an order-dependent way.
672        for (len, c) in chosen.iter().enumerate() {
673            let c = c.load(Ordering::Relaxed);
674            if c > 0 {
675                fld.add_val(len, (c as f64).ln());
676            }
677        }
678        fld.cache();
679        let tally: Vec<u64> = self
680            .fmt_counts
681            .iter()
682            .map(|a| a.load(Ordering::Relaxed))
683            .collect();
684        let detected = if tally.iter().sum::<u64>() > 0 {
685            Some(crate::infer_format_from_counts(&tally, ReadType::PairedEnd))
686        } else {
687            None
688        };
689        (fld, detected)
690    }
691}
692
693/// Where a run's fragment-length distribution came from, reported as
694/// `frag_length_source` in `aux_info/meta_info.json`.
695///
696/// Recorded because the distribution's provenance changes how much to trust it,
697/// and because it decides whether the user's `--fldMean`/`--fldSD` mattered.
698///
699/// `--fldMean`/`--fldSD` are priors, so which of these applies decides whether
700/// they influenced the result at all: they fully determine [`Self::Prior`], seed
701/// [`Self::Reads`]/[`Self::Alignments`]/[`Self::RadDerived`] with weight 1
702/// against the observations, and are not consulted at all for the two baked
703/// variants.
704#[derive(Debug, Clone, Copy, PartialEq, Eq)]
705pub enum FragLengthSource {
706    /// Trained during the read-mapping pass (reads mode).
707    Reads,
708    /// Derived from the input alignments (alignment mode, `-a`).
709    Alignments,
710    /// Read from the RAD header, where it was observed by the paired-end run
711    /// that wrote the file.
712    RadBaked,
713    /// Read from the RAD header, but written by a *single-end* run. No fragment
714    /// lengths existed to observe, so the baked distribution is that run's
715    /// `--fldMean`/`--fldSD` prior rather than an empirical distribution.
716    RadBakedPrior,
717    /// Derived at read time from this RAD's uniquely-mapped proper pairs
718    /// (a piscem RAD, or `--fldPolicy derive`).
719    RadDerived,
720    /// The `--fldMean`/`--fldSD` prior alone, with no observations folded in.
721    Prior,
722}
723
724impl FragLengthSource {
725    /// The `meta_info.json` spelling.
726    pub fn as_str(self) -> &'static str {
727        match self {
728            Self::Reads => "reads",
729            Self::Alignments => "alignments",
730            Self::RadBaked => "rad_baked",
731            Self::RadBakedPrior => "rad_baked_prior",
732            Self::RadDerived => "rad_derived",
733            Self::Prior => "prior",
734        }
735    }
736
737    /// Whether `--fldMean`/`--fldSD`/`--fldMax` were consulted at all. False
738    /// only for the baked variants, which take the distribution verbatim from
739    /// the RAD header.
740    ///
741    /// Used to warn when a user supplies those flags in a mode that ignores them,
742    /// rather than silently discarding the request.
743    pub fn uses_fld_prior_args(self) -> bool {
744        !matches!(self, Self::RadBaked | Self::RadBakedPrior)
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    /// A probability distribution must sum to 1; this catches an error in the
753    /// uniform prior's closed-form initialization.
754    #[test]
755    fn uniform_prior_pmf_normalizes() {
756        let mut fld = FragmentLengthDistribution::new(1.0, 200, 0.0, 0.0, 4, 0.5, 1);
757        fld.cache();
758        let total: f64 = fld.log_pmf().iter().map(|p| p.exp()).sum();
759        assert!((total - 1.0).abs() < 1e-9, "pmf sums to {total}");
760    }
761
762    /// And the Gaussian prior must actually be centred where it was asked to be,
763    /// which exercises the continuous-to-discrete conversion.
764    #[test]
765    fn gaussian_prior_mean_is_near_mu() {
766        let fld = FragmentLengthDistribution::new(1000.0, 1000, 250.0, 25.0, 4, 0.5, 1);
767        let m = fld.mean();
768        assert!((m - 250.0).abs() < 5.0, "mean {m} not near 250");
769    }
770
771    /// Data must be able to overrule the prior: enough observations at 400 have to
772    /// pull a distribution primed at 250 across.
773    #[test]
774    fn observations_shift_the_distribution() {
775        let mut fld = FragmentLengthDistribution::new(1.0, 1000, 250.0, 25.0, 4, 0.5, 1);
776        // pile observations around 400
777        for _ in 0..100_000 {
778            fld.add_val(400, 0.0); // mass = log(1) = 0
779        }
780        let m = fld.mean();
781        assert!(m > 300.0, "mean {m} did not move toward 400");
782        fld.cache();
783        // length 400 should be among the most probable
784        let p400 = fld.pmf(400);
785        let p250 = fld.pmf(250);
786        assert!(p400 > p250, "p(400)={p400} not > p(250)={p250}");
787    }
788
789    /// The behaviour the smoothed estimator exists for: a short transcript must be
790    /// shrunk rather than silently left uncorrected, a long one barely touched,
791    /// and a degenerate one fall back to its raw length.
792    #[test]
793    fn smoothed_efflen_shrinks_short_transcripts() {
794        // Gaussian prior mean 250: a transcript far shorter than the mean should
795        // get a heavily shrunk effective length (NOT the raw refLen the old
796        // truncated-PMF estimate fell back to).
797        let mut fld = FragmentLengthDistribution::new(1000.0, 1000, 250.0, 25.0, 4, 0.5, 1);
798        fld.cache();
799        let cm = fld.conditional_means();
800        // conditional means are non-decreasing
801        for w in cm.windows(2) {
802            assert!(
803                w[1] >= w[0] - 1e-9,
804                "cond means not monotonic: {} < {}",
805                w[1],
806                w[0]
807            );
808        }
809        let short = smoothed_effective_length(&cm, 201);
810        assert!(
811            short < 201.0 && short > 1.0,
812            "short effLen {short} not shrunk"
813        );
814        // a long transcript keeps most of its length
815        let long = smoothed_effective_length(&cm, 5000);
816        assert!(long > 4000.0, "long effLen {long} shrunk too much");
817        // below the 1.0 barrier the raw length is returned
818        let tiny = smoothed_effective_length(&cm, 2);
819        assert_eq!(tiny, 2.0, "tiny transcript should fall back to refLen");
820    }
821
822    /// The orphan weight must respond to how much room the mate leaves, in both
823    /// orientations, and stay neutral when no model exists yet.
824    #[test]
825    fn ambig_frag_prob_bounds_and_orientation() {
826        let mut fld = FragmentLengthDistribution::new(1000.0, 1000, 250.0, 25.0, 4, 0.5, 1);
827        fld.cache();
828        // Use the cached (frozen) CMF as a stand-in for the online snapshot.
829        let cmf = fld.cached_cmf.clone();
830        let txp_len = 2000i32;
831        // A forward read with ample downstream space (mate fits at the typical
832        // insert) should be near log(1) ≈ 0, since cmf(maxFrag) ≈ cmf(txpLen).
833        let ample = ambig_frag_log_prob(&cmf, true, 100, 75, txp_len);
834        assert!(ample > -0.01, "ample-space orphan logProb {ample} not ~0");
835        // A forward read crammed against the 3' end (little downstream space)
836        // implies an implausibly short fragment -> much smaller probability.
837        let crammed = ambig_frag_log_prob(&cmf, true, txp_len - 50, 75, txp_len);
838        assert!(
839            crammed < ample - 1.0,
840            "crammed orphan {crammed} not << ample {ample}"
841        );
842        // Reverse-strand orientation uses pos + read_len for the upstream bound:
843        // a reverse read whose outer end is near the 5' start is likewise crammed.
844        let rc_crammed = ambig_frag_log_prob(&cmf, false, 0, 50, txp_len);
845        assert!(
846            rc_crammed < ample - 1.0,
847            "rc crammed orphan {rc_crammed} not << ample {ample}"
848        );
849        // Empty snapshot -> unmodelled (LOG_1 = 0).
850        assert_eq!(ambig_frag_log_prob(&[], true, 100, 75, txp_len), 0.0);
851    }
852
853    /// A cumulative distribution can only increase, and must reach exactly
854    /// probability 1 at the end — the two properties every consumer relies on.
855    #[test]
856    fn cmf_is_monotonic() {
857        let mut fld = FragmentLengthDistribution::new(1000.0, 500, 200.0, 30.0, 4, 0.5, 1);
858        fld.cache();
859        let mut prev = f64::NEG_INFINITY;
860        for l in 0..=500 {
861            let c = fld.cmf(l);
862            assert!(c >= prev - 1e-9, "cmf decreased at {l}: {c} < {prev}");
863            prev = c;
864        }
865        assert!((prev - 0.0).abs() < 1e-6, "cmf endpoint {prev} != log(1)");
866    }
867}