Skip to main content

salmon_model/
gcbias.rs

1//! Fragment-GC bias model (`GCFragModel`).
2//!
3//! A port of salmon's `GCFragModel` (`include/.../model/GCFragModel.hpp`): a
4//! `condBins × numGCBins` table of fragment counts, where a fragment is keyed by
5//! its **GC fraction** (0–100, → `numGCBins` bins) and a **conditioning context**
6//! (a coarse sequence/context fraction, → `condBins` bins). An *observed* model
7//! (from mapped fragments) and an *expected* model (from the transcriptome) are
8//! each normalized per conditioning row into a distribution, then divided —
9//! [`gc_ratio`] — to give the per-`(context, GC)` bias factor used to weight
10//! fragments in the bias-corrected effective length.
11//!
12//! salmon accumulates the observed model in log space and the expected in linear
13//! space, but both are normalized to linear distributions before the ratio is
14//! taken, so the normalized result is independent of the accumulation space.
15//! We therefore accumulate in **linear** space (summing weights) throughout —
16//! numerically equivalent after [`GcFragModel::normalize`], and simpler.
17
18/// salmon's default number of conditioning (context) bins (`numConditionalGCBins`).
19pub const DEFAULT_COND_BINS: usize = 3;
20/// salmon's default number of fragment-GC bins for accumulation
21/// (`salmon::defaults::numFragGCBins`).
22pub const DEFAULT_GC_BINS: usize = 25;
23/// salmon's `ratio()` clamp (`gcBias = gcCounts.ratio(transcriptGCDist, 1000.0)`).
24pub const GC_MAX_RATIO: f64 = 1000.0;
25/// salmon's per-row normalization prior.
26const NORM_PRIOR: f64 = 0.1;
27
28/// Bin a 0–100 fraction into `n` bins (salmon's `GCDesc::fragBin(n)`/`contextBin(n)`:
29/// `min(n-1, floor(frac / (100/n)))`). With `n == 101` this is the identity.
30#[inline]
31pub fn bin_frac(frac: i32, n: usize) -> usize {
32    if n == 101 {
33        return (frac.clamp(0, 100)) as usize;
34    }
35    let w = 100.0 / n as f64;
36    let b = (frac as f64 / w) as i32;
37    b.clamp(0, n as i32 - 1) as usize
38}
39
40/// `(condBins × numGCBins)` table of fragment-GC counts (then a normalized
41/// distribution / ratio after [`normalize`](Self::normalize) / [`gc_ratio`]).
42#[derive(Debug, Clone)]
43pub struct GcFragModel {
44    cond_bins: usize,
45    gc_bins: usize,
46    /// row-major `counts[ctx * gc_bins + gc]`. Until [`normalize`], this is the
47    /// f64 *view* materialized from `counts_fp`; the live accumulator during
48    /// collection is the integer `counts_fp` (for thread-count-independent sums).
49    counts: Vec<f64>,
50    /// Fixed-point integer accumulator (`mass * BIAS_WEIGHT_SCALE`, truncated);
51    /// summed order-independently across worker threads, materialized into
52    /// `counts` once at [`normalize`]. See [`crate::BIAS_WEIGHT_SCALE`].
53    counts_fp: Vec<u64>,
54    normalized: bool,
55    /// Precomputed `[0..=100] -> bin` maps for the context and GC fractions
56    /// (both always lie in `[0, 100]`). [`bin_frac`] is ~44% of the eff-length
57    /// convolution self-time (two `100.0/n` + `frac/w` divides per [`get`]/[`inc`]
58    /// call, called per fragment); these LUTs replace both divides with a byte
59    /// load. Each entry is exactly `bin_frac(frac, n)`, so the result is
60    /// bit-identical to the divide path (verified in tests).
61    ctx_lut: Vec<u8>,
62    gc_lut: Vec<u8>,
63}
64
65impl GcFragModel {
66    pub fn new(cond_bins: usize, gc_bins: usize) -> Self {
67        let ctx_lut = (0..=100).map(|f| bin_frac(f, cond_bins) as u8).collect();
68        let gc_lut = (0..=100).map(|f| bin_frac(f, gc_bins) as u8).collect();
69        Self {
70            cond_bins,
71            gc_bins,
72            counts: vec![0.0; cond_bins * gc_bins],
73            counts_fp: vec![0u64; cond_bins * gc_bins],
74            normalized: false,
75            ctx_lut,
76            gc_lut,
77        }
78    }
79
80    /// salmon's default 3 × 101 model.
81    pub fn default_model() -> Self {
82        Self::new(DEFAULT_COND_BINS, DEFAULT_GC_BINS)
83    }
84
85    #[inline]
86    fn idx(&self, ctx_frac: i32, gc_frac: i32) -> usize {
87        // Clamp-then-LUT is bit-identical to `bin_frac` for every i32 input:
88        // for frac in [0,100] the LUT entry *is* `bin_frac(frac, n)`, and a frac
89        // outside [0,100] clamps to the same boundary bin `bin_frac` would
90        // produce (0 or n-1). Inputs are always lrint(100·ratio) in [0,100].
91        let ctx = if self.cond_bins > 1 {
92            self.ctx_lut[ctx_frac.clamp(0, 100) as usize] as usize
93        } else {
94            0
95        };
96        let gc = self.gc_lut[gc_frac.clamp(0, 100) as usize] as usize;
97        ctx * self.gc_bins + gc
98    }
99
100    /// The flattened `cond_bins × gc_bins` table (`counts[ctx * gc_bins + gc]`,
101    /// row-major), for dumping to the aux bias files.
102    pub fn dump(&self) -> &[f64] {
103        &self.counts
104    }
105
106    /// Accumulate `weight` for a fragment with the given GC and context fractions.
107    pub fn inc(&mut self, gc_frac: i32, ctx_frac: i32, weight: f64) {
108        debug_assert!(!self.normalized, "cannot inc a normalized model");
109        let i = self.idx(ctx_frac, gc_frac);
110        self.counts_fp[i] += crate::bias_mass_to_fp(weight);
111    }
112
113    /// Value at a `(context, GC)` cell (a normalized density after `normalize`,
114    /// or a clamped ratio for a model produced by [`gc_ratio`]).
115    #[inline]
116    pub fn get(&self, gc_frac: i32, ctx_frac: i32) -> f64 {
117        self.counts[self.idx(ctx_frac, gc_frac)]
118    }
119
120    /// Merge another (compatible) model's counts. Both must be pre-normalization.
121    pub fn combine_counts(&mut self, other: &GcFragModel) {
122        debug_assert!(!self.normalized && !other.normalized);
123        // Integer sum — associative, so the merged model is independent of how
124        // fragments were partitioned across worker threads.
125        for (a, b) in self.counts_fp.iter_mut().zip(&other.counts_fp) {
126            *a += *b;
127        }
128    }
129
130    /// Normalize each conditioning row into a distribution over GC bins, with a
131    /// pseudocount `prior` (salmon's default 0.1). Idempotent.
132    pub fn normalize(&mut self) {
133        if self.normalized {
134            return;
135        }
136        // Materialize the integer accumulator into the f64 `counts` the rest of
137        // the model (rows, `get`, `gc_ratio`, dumps) reads.
138        for (c, &fp) in self.counts.iter_mut().zip(&self.counts_fp) {
139            *c = fp as f64 / crate::BIAS_WEIGHT_SCALE;
140        }
141        for r in 0..self.cond_bins {
142            let base = r * self.gc_bins;
143            let row = &mut self.counts[base..base + self.gc_bins];
144            let row_mass: f64 = row.iter().map(|c| NORM_PRIOR + *c).sum();
145            if row_mass > 0.0 {
146                let norm = 1.0 / row_mass;
147                for c in row.iter_mut() {
148                    *c = (NORM_PRIOR + *c) * norm;
149                }
150            }
151        }
152        self.normalized = true;
153    }
154}
155
156/// Per-cell bias ratio `observed / expected`, clamped to `[1/max_ratio, max_ratio]`
157/// (salmon's `GCFragModel::ratio`). Both models are normalized first.
158pub fn gc_ratio(
159    observed: &mut GcFragModel,
160    expected: &mut GcFragModel,
161    max_ratio: f64,
162) -> GcFragModel {
163    observed.normalize();
164    expected.normalize();
165    let min_ratio = 1.0 / max_ratio;
166    let mut out = GcFragModel::new(observed.cond_bins, observed.gc_bins);
167    for i in 0..out.counts.len() {
168        let e = expected.counts[i];
169        let rat = if e != 0.0 {
170            observed.counts[i] / e
171        } else {
172            max_ratio
173        };
174        out.counts[i] = rat.clamp(min_ratio, max_ratio);
175    }
176    out.normalized = true; // a ratio model is used directly, not re-normalized
177    out
178}
179
180// ===========================================================================
181// Fragment-GC content (salmon's `Transcript::GCCount_` / `gcFrac` / `gcDesc`)
182// ===========================================================================
183
184use crate::seqbias::{
185    conditional_cdf, log_bias, revcomp_bytes, SBModel, CONTEXT_LEFT, CONTEXT_LENGTH, MIN_ALPHA,
186    MIN_CDF_MASS,
187};
188
189/// salmon's fragment-length sampling stride for the GC convolution
190/// (`pdfSampFactor` = `biasSpeedSamp`, default 5).
191pub const GC_SAMP_STRIDE: usize = 5;
192
193/// `gcDesc` context-window geometry (salmon `Transcript::gcDesc`):
194/// `outsideContext = 3`, `insideContext = 2`.
195const OUTSIDE_5P: i32 = 4; // outsideContext + 1
196const OUTSIDE_3P: i32 = 3; // outsideContext
197const INSIDE_5P: i32 = 1; // insideContext - 1
198const INSIDE_3P: i32 = 2; // insideContext
199
200/// Round to nearest (ties to even), matching C++ `std::lrint` under the default
201/// rounding mode.
202///
203/// On x86-64 this is a single `cvtsd2si` instruction (which rounds per the
204/// current — default round-to-nearest-even — MXCSR mode), identical to salmon's
205/// `std::lrint`. The portable `f64::round_ties_even() as i32` lowers to a libm
206/// `roundeven` *function call* on this baseline build; since this is called
207/// ~2× per `gc_desc` over tens of billions of fragment evaluations under
208/// `--gcBias`, that call was the dominant per-iteration cost (≈4× the GC-bias
209/// runtime vs C++). Other architectures keep the portable path.
210#[inline]
211fn lrint(x: f64) -> i32 {
212    #[cfg(target_arch = "x86_64")]
213    {
214        // SAFETY: SSE2 is part of the x86-64 baseline, so `_mm_set_sd` /
215        // `_mm_cvtsd_si32` are always available. `cvtsd2si` matches `std::lrint`
216        // (round-to-nearest-even); our arguments are small (0–100), no overflow.
217        #[allow(unsafe_code)]
218        unsafe {
219            core::arch::x86_64::_mm_cvtsd_si32(core::arch::x86_64::_mm_set_sd(x))
220        }
221    }
222    #[cfg(not(target_arch = "x86_64"))]
223    {
224        x.round_ties_even() as i32
225    }
226}
227
228/// Cumulative G+C counts (salmon's `Transcript::GCCount_`): `prefix[p]` is the
229/// number of `G`/`C` bases in `seq[0..=p]`.
230pub fn gc_prefix(seq: &[u8]) -> Vec<u32> {
231    let mut prefix = Vec::with_capacity(seq.len());
232    let mut acc = 0u32;
233    for &b in seq {
234        if matches!(b, b'G' | b'g' | b'C' | b'c') {
235            acc += 1;
236        }
237        prefix.push(acc);
238    }
239    prefix
240}
241
242/// GC count in the closed interval `[a, b]` from a cumulative-count prefix.
243#[inline]
244fn gc_in(prefix: &[u32], a: i32, b: i32) -> i64 {
245    let lo = if a > 0 {
246        prefix[(a - 1) as usize] as i64
247    } else {
248        0
249    };
250    prefix[b as usize] as i64 - lo
251}
252
253/// Fragment GC fraction (0–100) over the closed interval `[s, e]`
254/// (salmon's `Transcript::gcFrac`): `round(100·GC[s,e] / (e−s+1))`.
255#[inline]
256pub fn gc_frac(prefix: &[u32], s: i32, e: i32) -> i32 {
257    lrint(100.0 * gc_in(prefix, s, e) as f64 / (e - s + 1) as f64)
258}
259
260/// A rank-enabled bitvector over the *concatenated* reference sequence marking
261/// G/C positions (salmon's `--reduceGCMemory` representation). Replaces the
262/// per-transcript dense cumulative-GC arrays (`Vec<Vec<u32>>`, 4 bytes/base) with
263/// one bitvector (~1 bit/base + 25% rank overhead) — GC in any interval is a
264/// difference of two O(1) ranks. Build once over the whole transcriptome.
265pub struct GcRank {
266    rank: sux::rank_sel::Rank9,
267}
268
269impl GcRank {
270    /// Build the rank bitvector from the concatenated reference bytes
271    /// (`seq[i]` is G/C ⇒ bit `i` set).
272    pub fn new(concat: &[u8]) -> GcRank {
273        use sux::traits::bit_vec_ops::BitVecOpsMut;
274        let mut bv = sux::bits::BitVec::new(concat.len());
275        for (i, &b) in concat.iter().enumerate() {
276            if matches!(b, b'G' | b'g' | b'C' | b'c') {
277                bv.set(i, true);
278            }
279        }
280        GcRank {
281            rank: sux::rank_sel::Rank9::new(bv),
282        }
283    }
284
285    /// A cumulative-GC view of the transcript occupying `concat[off..off+len]`.
286    #[inline]
287    pub fn view(&self, off: usize, len: usize) -> GcView<'_> {
288        use sux::traits::Rank;
289        GcView::Rank {
290            rank: &self.rank,
291            off,
292            base: self.rank.rank(off),
293            len,
294        }
295    }
296}
297
298/// A per-transcript cumulative-GC accessor: either the dense prefix array
299/// (default) or a window of the shared [`GcRank`] (`--reduceGCMemory`). Both
300/// return the *same* cumulative counts, so results are identical; `cum(p)` is
301/// the number of G/C bases in transcript-local `[0, p]`.
302#[derive(Clone, Copy)]
303pub enum GcView<'a> {
304    Dense(&'a [u32]),
305    Rank {
306        rank: &'a sux::rank_sel::Rank9,
307        off: usize,
308        /// `rank(off)` — precomputed so `cum` is a single rank query.
309        base: usize,
310        len: usize,
311    },
312}
313
314impl GcView<'_> {
315    /// Cumulative G/C count in transcript-local `[0, p]` (inclusive).
316    #[inline]
317    pub fn cum(&self, p: i32) -> i64 {
318        match self {
319            GcView::Dense(prefix) => prefix[p as usize] as i64,
320            GcView::Rank {
321                rank, off, base, ..
322            } => {
323                use sux::traits::Rank;
324                (rank.rank(off + p as usize + 1) - base) as i64
325            }
326        }
327    }
328
329    /// Transcript length in bases.
330    #[inline]
331    pub fn ref_len(&self) -> usize {
332        match self {
333            GcView::Dense(prefix) => prefix.len(),
334            GcView::Rank { len, .. } => *len,
335        }
336    }
337}
338
339/// A whole-transcriptome source of per-transcript [`GcView`]s: either dense
340/// per-transcript prefixes (default) or one shared [`GcRank`] window per
341/// transcript (`--reduceGCMemory`). Lets callers stay representation-agnostic.
342#[derive(Clone, Copy)]
343pub enum GcStore<'a> {
344    Dense(&'a [Vec<u32>]),
345    Rank {
346        rank: &'a GcRank,
347        offsets: &'a [u64],
348    },
349}
350
351impl<'a> GcStore<'a> {
352    /// The cumulative-GC view for transcript `tid`. The view borrows the
353    /// underlying data (lifetime `'a`), not `self`, so it outlives a temporary
354    /// `GcStore`.
355    #[inline]
356    pub fn view(&self, tid: usize) -> GcView<'a> {
357        match *self {
358            GcStore::Dense(prefixes) => GcView::Dense(&prefixes[tid]),
359            GcStore::Rank { rank, offsets } => {
360                let off = offsets[tid] as usize;
361                let len = (offsets[tid + 1] - offsets[tid]) as usize;
362                rank.view(off, len)
363            }
364        }
365    }
366}
367
368/// Fragment GC descriptor `(fragFrac, contextFrac)` for the closed fragment
369/// `[s, e]` (salmon's `Transcript::gcDesc`). The context fraction is the GC
370/// content over a 5-base 5' window `[s−3, s+1]` and a 5-base 3' window
371/// `[e−1, e+3]` (edge-clamped). Returns `None` when the context window is empty
372/// (matching salmon's `valid = false`).
373#[inline]
374pub fn gc_desc(v: &GcView, s: i32, e: i32) -> Option<(i32, i32)> {
375    let last = v.ref_len() as i32 - 1;
376    let cs = if s > 0 { v.cum(s - 1) } else { 0 };
377    let ce = v.cum(e);
378
379    let fs = s - OUTSIDE_5P;
380    let fe = s + INSIDE_5P;
381    let ts = e - INSIDE_3P;
382    let te = e + OUTSIDE_3P;
383
384    let fp_left = fs >= 0;
385    let fp_right = fe <= last;
386    let tp_left = ts >= 0;
387    let tp_right = te <= last;
388
389    let fps = if fp_left { v.cum(fs) } else { 0 };
390    let fpe = if fp_right { v.cum(fe) } else { ce };
391    let tps = if tp_left { v.cum(ts) } else { 0 };
392    let tpe = if tp_right { v.cum(te) } else { ce };
393
394    let fs_c = fs.max(0);
395    let fe_c = fe.min(last);
396    let ts_c = ts.max(0);
397    let te_c = te.min(last);
398    let fp_context_size = if !fp_left { fe_c + 1 } else { fe_c - fs_c };
399    let tp_context_size = if !tp_left { te_c + 1 } else { te_c - ts_c };
400    let context_size = (fp_context_size + tp_context_size) as f64;
401    if context_size == 0.0 {
402        return None;
403    }
404
405    let frag_frac = lrint(100.0 * (ce - cs) as f64 / (e - s + 1) as f64);
406    let context_frac = lrint(100.0 * ((fpe - fps) + (tpe - tps)) as f64 / context_size);
407    Some((frag_frac, context_frac))
408}
409
410/// Per-position context-GC cache for a single transcript — salmon's
411/// `populateContextCounts`. [`gc_desc`] recomputes the 5'/3' context window
412/// geometry (four conditional prefix loads, four edge branches, a division)
413/// for *every* `(fragStart, fragEnd)` pair in the effective-length convolution;
414/// since the 5' term depends only on `fragStart` and the 3' term only on
415/// `fragEnd`, we hoist both to O(ref_len) per-position arrays and reduce the
416/// inner loop to two array loads + one division + one `lrint` — matching the
417/// C++ hot path. [`GcContext::desc`] is bit-identical to [`gc_desc`] for every
418/// fragment the convolution actually visits (`fragStart + INSIDE_5P <= last`,
419/// i.e. `fp_right` always holds — guaranteed since `fragStart <= ref_len-2`).
420pub struct GcContext {
421    cum: Vec<u32>,      // cumulative G/C count in [0, p], indexed by position
422    fp_count: Vec<i64>, // 5' window GC count, indexed by fragStart
423    fp_wlen: Vec<i32>,  // 5' window length, indexed by fragStart
424    tp_count: Vec<i64>, // 3' window GC count, indexed by fragEnd
425    tp_wlen: Vec<i32>,  // 3' window length, indexed by fragEnd
426}
427
428impl GcContext {
429    /// Precompute the per-position 5'/3' context arrays (and a transient copy of
430    /// the cumulative-GC counts) from any [`GcView`]. Each entry replicates
431    /// exactly the corresponding sub-expression of [`gc_desc`] (same
432    /// edge-clamping, same `fp_right`/`tp_right` handling). Materializing `cum`
433    /// here keeps the hot inner loop ([`GcContext::desc`]) free of any rank
434    /// queries or dense lookups beyond cached arrays.
435    pub fn build(v: &GcView) -> GcContext {
436        let n = v.ref_len();
437        let last = n as i32 - 1;
438        let cum: Vec<u32> = (0..n).map(|p| v.cum(p as i32) as u32).collect();
439        let at = |i: i32| cum[i as usize] as i64;
440        let mut fp_count = vec![0i64; n];
441        let mut fp_wlen = vec![0i32; n];
442        let mut tp_count = vec![0i64; n];
443        let mut tp_wlen = vec![0i32; n];
444        for p in 0..n {
445            let pos = p as i32;
446            // 3' window for a fragment *ending* at `pos`.
447            let ts = pos - INSIDE_3P;
448            let te = pos + OUTSIDE_3P;
449            let tp_left = ts >= 0;
450            let tp_right = te <= last;
451            let tps = if tp_left { at(ts) } else { 0 };
452            // `tpe = ce = cum[e]` when the 3' window runs off the end.
453            let tpe = if tp_right { at(te) } else { at(pos) };
454            let ts_c = ts.max(0);
455            let te_c = te.min(last);
456            tp_count[p] = tpe - tps;
457            tp_wlen[p] = if !tp_left { te_c + 1 } else { te_c - ts_c };
458
459            // 5' window for a fragment *starting* at `pos`. Within the
460            // convolution `fp_right` always holds; the `cum[p]` fallback is a
461            // placeholder for the never-visited `pos == last` slot.
462            let fs = pos - OUTSIDE_5P;
463            let fe = pos + INSIDE_5P;
464            let fp_left = fs >= 0;
465            let fp_right = fe <= last;
466            let fps = if fp_left { at(fs) } else { 0 };
467            let fpe = if fp_right { at(fe) } else { at(pos) };
468            let fs_c = fs.max(0);
469            let fe_c = fe.min(last);
470            fp_count[p] = fpe - fps;
471            fp_wlen[p] = if !fp_left { fe_c + 1 } else { fe_c - fs_c };
472        }
473        GcContext {
474            cum,
475            fp_count,
476            fp_wlen,
477            tp_count,
478            tp_wlen,
479        }
480    }
481
482    /// GC descriptor `(fragFrac, contextFrac)` for the closed fragment `[s, e]`,
483    /// bit-identical to [`gc_desc`] but using the cached context arrays. `s` and
484    /// `e` must lie in `0..ref_len` with `s + INSIDE_5P <= ref_len - 1`.
485    #[inline]
486    pub fn desc(&self, s: i32, e: i32) -> Option<(i32, i32)> {
487        let context_size = (self.fp_wlen[s as usize] + self.tp_wlen[e as usize]) as f64;
488        if context_size == 0.0 {
489            return None;
490        }
491        let cs = if s > 0 {
492            self.cum[(s - 1) as usize] as i64
493        } else {
494            0
495        };
496        let ce = self.cum[e as usize] as i64;
497        let count = (self.fp_count[s as usize] + self.tp_count[e as usize]) as f64;
498        let frag_frac = lrint(100.0 * (ce - cs) as f64 / (e - s + 1) as f64);
499        let context_frac = lrint(100.0 * count / context_size);
500        Some((frag_frac, context_frac))
501    }
502}
503
504/// Build the *expected* fragment-GC model (salmon's `transcriptGCDist`): slide
505/// every fragment `[fragStart, fragStart+fl−1]` over each expressed transcript,
506/// weighting each by `(alpha/effLen)·(conditionalCDF(fl) − prevMass)` — the same
507/// abundance-density × conditional-FLD weighting used for the expected
508/// sequence-bias model. `k` is the leading offset salmon excludes from the
509/// fragment-start loop (`9` with `--seqBias`, `1` otherwise); `stride` subsamples
510/// fragment lengths ([`GC_SAMP_STRIDE`]).
511#[allow(clippy::too_many_arguments)]
512pub fn build_expected_gc<'a, FS, FP>(
513    num_targets: usize,
514    seq_of: FS,
515    view_of: FP,
516    alphas: &[f64],
517    eff_lens: &[f64],
518    cdf: &[f64],
519    fld_low: usize,
520    fld_high: usize,
521    cond_bins: usize,
522    gc_bins: usize,
523    k: usize,
524    stride: usize,
525) -> GcFragModel
526where
527    FS: Fn(usize) -> &'a [u8] + Sync,
528    FP: Fn(usize) -> GcView<'a> + Sync,
529{
530    let stride = stride.max(1) as i32;
531    // The expected-GC distribution is a sum of independent per-transcript
532    // contributions, each an O(refLen · fldRange/stride) double loop — billions
533    // of `gc_desc` calls in total. salmon parallelizes this over transcripts;
534    // do the same with rayon (per-thread `GcFragModel` partials, reduced via
535    // `combine_counts`). `seq_of`/`prefix_of` must be `Sync` to share across
536    // threads (they are: closures over `&[Vec<_>]`).
537    use rayon::prelude::*;
538    let per_tid = |tid: usize| -> Option<GcFragModel> {
539        if alphas[tid] < MIN_ALPHA || eff_lens[tid] <= 0.0 {
540            return None;
541        }
542        let seq = seq_of(tid);
543        let ref_len = seq.len();
544        if ref_len <= k {
545            return None;
546        }
547        let cdf_max_arg = (cdf.len() - 1).min(ref_len);
548        let cdf_max_val = cdf[cdf_max_arg];
549        if cdf_max_val < MIN_CDF_MASS {
550            return None;
551        }
552        let view = view_of(tid);
553        let ctx = GcContext::build(&view);
554        let weight = alphas[tid] / eff_lens[tid];
555        let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
556        let sp = if fld_low > 0 { fld_low as i32 - 1 } else { 0 };
557        let mut model = GcFragModel::new(cond_bins, gc_bins);
558        for frag_start in 0..(ref_len - k) {
559            let mut prev = cond(sp);
560            let mut fl = fld_low as i32;
561            while fl <= fld_high as i32 {
562                let frag_end = frag_start as i32 + fl - 1;
563                if (frag_end as usize) < ref_len {
564                    if let Some((ff, cf)) = ctx.desc(frag_start as i32, frag_end) {
565                        model.inc(ff, cf, weight * (cond(fl) - prev));
566                    }
567                    prev = cond(fl);
568                } else {
569                    break;
570                }
571                fl += stride;
572            }
573        }
574        Some(model)
575    };
576    (0..num_targets)
577        .into_par_iter()
578        .fold(
579            || GcFragModel::new(cond_bins, gc_bins),
580            |mut acc, tid| {
581                if let Some(m) = per_tid(tid) {
582                    acc.combine_counts(&m);
583                }
584                acc
585            },
586        )
587        .reduce(
588            || GcFragModel::new(cond_bins, gc_bins),
589            |mut a, b| {
590                a.combine_counts(&b);
591                a
592            },
593        )
594}
595
596/// Bias-corrected effective length including fragment-GC bias (and, when
597/// `seq_models` is provided, sequence-specific bias too). Mirrors salmon's
598/// combined `updateEffectiveLengths` convolution: per-position 5'/3' sequence
599/// factors are multiplied by the per-fragment `gcBias.get({fragFrac, contextFrac})`
600/// ratio, convolved with the conditional FLD, and floored at the lower barrier.
601///
602/// `gc_bias` is the normalized observed/expected ratio model ([`gc_ratio`]).
603/// `seq_models` is `(obs_fw, exp_fw, obs_rc, exp_rc)` when `--seqBias` is also on.
604#[allow(clippy::too_many_arguments)]
605pub fn gc_corrected_effective_length(
606    seq: &[u8],
607    prefix: &[u32],
608    cdf: &[f64],
609    fld_low: usize,
610    fld_high: usize,
611    gc_bias: &GcFragModel,
612    seq_models: Option<(&SBModel, &SBModel, &SBModel, &SBModel)>,
613    elen: f64,
614    stride: usize,
615) -> f64 {
616    let k = if seq_models.is_some() {
617        CONTEXT_LENGTH
618    } else {
619        1
620    };
621    let ref_len = seq.len();
622    let unprocessed = (ref_len as i32 - elen as i32).max(0);
623    let cdf_max_arg = (cdf.len() - 1).min(ref_len);
624    let cdf_max_val = cdf[cdf_max_arg];
625    if ref_len < k || unprocessed <= 0 || cdf_max_val < MIN_CDF_MASS {
626        return elen;
627    }
628    let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
629
630    // Per-position sequence-bias factors (1.0 when not seq-correcting).
631    let mut fw = vec![1.0f64; ref_len];
632    let mut rc = vec![1.0f64; ref_len];
633    if let Some((obs_fw, exp_fw, obs_rc, exp_rc)) = seq_models {
634        let cu = CONTEXT_LEFT;
635        let rc_seq = revcomp_bytes(seq);
636        for frag_start in 0..(ref_len - CONTEXT_LENGTH) {
637            let read_start = frag_start + cu;
638            if read_start < ref_len {
639                fw[read_start] = log_bias(
640                    obs_fw,
641                    exp_fw,
642                    &seq[frag_start..frag_start + CONTEXT_LENGTH],
643                    false,
644                )
645                .exp();
646                rc[read_start] = log_bias(
647                    obs_rc,
648                    exp_rc,
649                    &rc_seq[frag_start..frag_start + CONTEXT_LENGTH],
650                    false,
651                )
652                .exp();
653            }
654        }
655        rc.reverse();
656    }
657
658    // Convolve seq×gc fragment factors with the conditional FLD.
659    let ctx = GcContext::build(&GcView::Dense(prefix));
660    let stride = stride.max(1) as i32;
661    let max_len = (ref_len as i32).min(fld_high as i32 + 1);
662    let mut fl = fld_low as i32;
663    let mut done = fl >= max_len;
664    let sp = if fl > 0 { fl - 1 } else { 0 };
665    let mut prev_mass = cond(sp);
666    let mut eff = 0.0f64;
667    while !done {
668        if fl >= max_len {
669            done = true;
670            fl = max_len - 1;
671        }
672        let fl_weight = cond(fl) - prev_mass;
673        prev_mass = cond(fl);
674        let mut mass = 0.0f64;
675        // Hoist the bound: for kstart in [0, kmax) we have
676        // frag_end = kstart+fl-1 <= ref_len-2 < ref_len, so the old per-iteration
677        // `frag_end < ref_len` guard is always true and is dropped (it kept
678        // `ref_len` live in the inner loop, forcing a spill/reload).
679        let kmax = ref_len as i32 - fl;
680        let mut kstart = 0i32;
681        while kstart < kmax {
682            let frag_start = kstart;
683            let frag_end = kstart + fl - 1;
684            let mut frag_factor = fw[frag_start as usize] * rc[frag_end as usize];
685            if let Some((ff, cf)) = ctx.desc(frag_start, frag_end) {
686                frag_factor *= gc_bias.get(ff, cf);
687            }
688            mass += frag_factor;
689            kstart += 1;
690        }
691        eff += fl_weight * mass;
692        fl += stride;
693    }
694
695    let offset = (unprocessed as f64).max(1.0);
696    eff.max(elen.min(offset))
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn bin_frac_identity_at_101() {
705        assert_eq!(bin_frac(0, 101), 0);
706        assert_eq!(bin_frac(57, 101), 57);
707        assert_eq!(bin_frac(100, 101), 100);
708    }
709
710    #[test]
711    fn bin_frac_coarse() {
712        // 3 bins: width 33.33 -> [0,33),[33,66),[66,100]
713        assert_eq!(bin_frac(0, 3), 0);
714        assert_eq!(bin_frac(30, 3), 0);
715        assert_eq!(bin_frac(40, 3), 1);
716        assert_eq!(bin_frac(70, 3), 2);
717        assert_eq!(bin_frac(100, 3), 2); // clamped to n-1
718    }
719
720    #[test]
721    fn normalize_makes_rows_sum_to_one() {
722        let mut m = GcFragModel::new(2, 5);
723        for gc in 0..5 {
724            m.inc(gc * 25, 10, (gc + 1) as f64); // row 0 (ctx 10 -> bin 0)
725        }
726        m.normalize();
727        let row0: f64 = (0..5).map(|c| m.counts[c]).sum();
728        assert!((row0 - 1.0).abs() < 1e-9, "row0 sum = {row0}");
729    }
730
731    #[test]
732    fn ratio_of_identical_models_is_one() {
733        let mut obs = GcFragModel::new(1, 101);
734        let mut exp = GcFragModel::new(1, 101);
735        for gc in 0..=100 {
736            obs.inc(gc, 0, (gc + 1) as f64);
737            exp.inc(gc, 0, (gc + 1) as f64);
738        }
739        let r = gc_ratio(&mut obs, &mut exp, GC_MAX_RATIO);
740        for gc in 0..=100 {
741            assert!(
742                (r.get(gc, 0) - 1.0).abs() < 1e-9,
743                "gc {gc}: {}",
744                r.get(gc, 0)
745            );
746        }
747    }
748
749    #[test]
750    fn gc_frac_basic() {
751        // 10-base sequence, 5 G/C -> 50%
752        let seq = b"ACGTACGTAC"; // A C G T A C G T A C : GC at 1,2,5,6,9 = 5
753        let p = gc_prefix(seq);
754        assert_eq!(gc_frac(&p, 0, 9), 50);
755        // all-GC window
756        let seq2 = b"GGGGCCCC";
757        let p2 = gc_prefix(seq2);
758        assert_eq!(gc_frac(&p2, 0, 7), 100);
759        // single base
760        assert_eq!(gc_frac(&p, 2, 2), 100); // 'G'
761        assert_eq!(gc_frac(&p, 0, 0), 0); // 'A'
762    }
763
764    #[test]
765    fn gc_desc_matches_frag_and_context() {
766        // 40-base sequence; check an interior fragment
767        let seq: Vec<u8> = b"ACGT".iter().cycle().take(40).copied().collect();
768        let p = gc_prefix(&seq);
769        let (ff, cf) = gc_desc(&GcView::Dense(&p), 10, 29).unwrap();
770        // ACGT repeat is exactly 50% GC everywhere
771        assert_eq!(ff, 50, "fragFrac");
772        assert!((0..=100).contains(&cf), "contextFrac in range: {cf}");
773        // fragFrac must equal gc_frac over the same interval
774        assert_eq!(ff, gc_frac(&p, 10, 29));
775    }
776
777    #[test]
778    fn idx_lut_matches_bin_frac() {
779        // The LUT-based idx must equal the original divide-based formula for
780        // every (ctx, gc) fraction the model can see, including out-of-[0,100].
781        for &(cb, gb) in &[(3usize, 25usize), (1, 101), (3, 101), (4, 50)] {
782            let m = GcFragModel::new(cb, gb);
783            for cf in -5i32..=105 {
784                for ff in -5i32..=105 {
785                    let want_ctx = if cb > 1 { bin_frac(cf, cb) } else { 0 };
786                    let want = want_ctx * gb + bin_frac(ff, gb);
787                    assert_eq!(m.idx(cf, ff), want, "cb={cb} gb={gb} cf={cf} ff={ff}");
788                }
789            }
790        }
791    }
792
793    #[test]
794    fn gc_context_matches_gc_desc() {
795        // Cached context (GcContext::desc) must be bit-identical to the
796        // per-fragment gc_desc for every fragment the convolution can visit
797        // (fragStart in 0..ref_len-fl, fragEnd = fragStart+fl-1).
798        let bases = [b'A', b'C', b'G', b'T', b'C', b'G', b'A', b'T', b'G', b'C'];
799        let seq: Vec<u8> = (0..200).map(|i| bases[(i * 7 + 3) % bases.len()]).collect();
800        let p = gc_prefix(&seq);
801        let v = GcView::Dense(&p);
802        let ctx = GcContext::build(&v);
803        let n = seq.len() as i32;
804        for fl in 1..=120 {
805            let kmax = n - fl;
806            for s in 0..kmax {
807                let e = s + fl - 1;
808                assert_eq!(
809                    ctx.desc(s, e),
810                    gc_desc(&v, s, e),
811                    "mismatch at fl={fl}, s={s}, e={e}"
812                );
813            }
814        }
815    }
816
817    #[test]
818    fn gc_rank_view_matches_dense() {
819        // The rank-bitvector view must return identical cumulative GC (and hence
820        // identical gc_desc) to the dense prefix, for an offset window of a
821        // concatenated buffer.
822        let bases = [b'A', b'C', b'G', b'T', b'G', b'C', b'A', b'T'];
823        let pad: Vec<u8> = (0..37).map(|i| bases[(i * 3) % bases.len()]).collect();
824        let seq: Vec<u8> = (0..150).map(|i| bases[(i * 5 + 1) % bases.len()]).collect();
825        // concat = [pad | seq], so the transcript sits at offset pad.len().
826        let mut concat = pad.clone();
827        concat.extend_from_slice(&seq);
828        let p = gc_prefix(&seq);
829        let rank = GcRank::new(&concat);
830        let dense = GcView::Dense(&p);
831        let rview = rank.view(pad.len(), seq.len());
832        for fl in 1..=100 {
833            let n = seq.len() as i32;
834            for s in 0..(n - fl) {
835                let e = s + fl - 1;
836                assert_eq!(
837                    gc_desc(&dense, s, e),
838                    gc_desc(&rview, s, e),
839                    "fl={fl} s={s}"
840                );
841            }
842        }
843    }
844
845    #[test]
846    fn gc_desc_context_window_geometry() {
847        // Make the 5' context window [s-3, s+1] all GC and the rest AT, so the
848        // contextFrac is dominated by the GC island near the fragment ends.
849        let mut seq = vec![b'A'; 60];
850        for i in 17..=21 {
851            seq[i] = b'G'; // 5' window for s=20 is [17,21]
852        }
853        for i in 39..=43 {
854            seq[i] = b'C'; // 3' window for e=40 is [39,43]
855        }
856        let p = gc_prefix(&seq);
857        let (_ff, cf) = gc_desc(&GcView::Dense(&p), 20, 40).unwrap();
858        // both 5-base context windows are fully G/C -> 100%
859        assert_eq!(cf, 100, "contextFrac");
860    }
861
862    #[test]
863    fn ratio_clamped() {
864        let mut obs = GcFragModel::new(1, 2);
865        let mut exp = GcFragModel::new(1, 2);
866        obs.inc(0, 0, 1e6); // bin 0 hugely enriched in obs
867        exp.inc(75, 0, 1e6); // bin 1 enriched in exp
868        let r = gc_ratio(&mut obs, &mut exp, 1000.0);
869        for v in [r.get(0, 0), r.get(75, 0)] {
870            assert!(
871                (1.0 / 1000.0 - 1e-12..=1000.0 + 1e-6).contains(&v),
872                "ratio {v} out of clamp"
873            );
874        }
875    }
876}