Skip to main content

salmon_model/
seqbias.rs

1//! Sequence-specific bias model (`SBModel`).
2//!
3//! A faithful port of salmon's `SBModel` (`src/model/SBModel.cpp`): a
4//! variable-order Markov model over the 9-base sequence context surrounding a
5//! fragment's start position (3 bases before the start, the start, and 5 after).
6//! Per-position Markov orders are `{0,1,2,2,2,2,2,2,2}`. Counts are accumulated
7//! from observed fragment-start contexts (the *observed* model) and from the
8//! transcriptome (the *expected* model); the ratio of the two scores the
9//! sequence bias at any position, which is used to correct effective lengths.
10//!
11//! The 2-bit base encoding only needs to be self-consistent (the bias is a
12//! ratio of two models built with the same encoding), so we use A=0, C=1,
13//! G=2, T=3.
14
15use realfft::RealFftPlanner;
16use std::cell::RefCell;
17
18thread_local! {
19    /// Per-thread FFT planner (the per-transcript correction runs in a parallel
20    /// sweep). Padding to a power of two keeps the set of distinct plan sizes
21    /// tiny, so plans are reused across transcripts.
22    static FFT_PLANNER: RefCell<RealFftPlanner<f64>> = RefCell::new(RealFftPlanner::<f64>::new());
23}
24
25/// Cross-correlation `xc[Δ] = Σ_k a[k]·b[k+Δ]` for `Δ in [0, max_lag]`, via a
26/// real FFT (zero-padded so `b` beyond its length contributes 0 — i.e. linear,
27/// not circular, correlation). Correlation theorem: `corr(a,b) =
28/// IFFT(conj(FFT(a))·FFT(b))`. rustfft is unnormalized, so divide by `n`.
29fn xcorr_fft(fw: &[f64], rc: &[f64], max_lag: usize) -> Vec<f64> {
30    let l = fw.len();
31    debug_assert_eq!(l, rc.len());
32    let n = (l + max_lag + 1).next_power_of_two().max(2);
33    FFT_PLANNER.with(|p| {
34        let mut planner = p.borrow_mut();
35        let r2c = planner.plan_fft_forward(n);
36        let c2r = planner.plan_fft_inverse(n);
37        let mut a = r2c.make_input_vec();
38        let mut b = r2c.make_input_vec();
39        a[..l].copy_from_slice(fw);
40        b[..l].copy_from_slice(rc);
41        let mut fa = r2c.make_output_vec();
42        let mut fb = r2c.make_output_vec();
43        r2c.process(&mut a, &mut fa).expect("rfft fw");
44        r2c.process(&mut b, &mut fb).expect("rfft rc");
45        for (x, y) in fa.iter_mut().zip(&fb) {
46            *x = x.conj() * *y;
47        }
48        let mut out = c2r.make_output_vec();
49        c2r.process(&mut fa, &mut out).expect("irfft");
50        let scale = 1.0 / n as f64;
51        out[..=max_lag].iter().map(|v| v * scale).collect()
52    })
53}
54
55/// Per-position Markov orders (salmon's "simple" model). Length is the context.
56const ORDER: [u32; 9] = [0, 1, 2, 2, 2, 2, 2, 2, 2];
57/// Context length (= ORDER.len()): 3 left + start + 5 right.
58pub const CONTEXT_LENGTH: usize = 9;
59/// Bases before the fragment-start position.
60pub const CONTEXT_LEFT: usize = 3;
61/// Bases at/after the fragment-start position.
62pub const CONTEXT_RIGHT: usize = 5;
63/// Rows in the probability table: 4^(maxOrder+1) = 4^3.
64const ROWS: usize = 64;
65/// Pseudocount prior.
66const PRIOR: f64 = 1e-10;
67/// Floor used when taking the log of a zero probability.
68const LOG_SMALL: f64 = -11.512_925_464_970_229; // ln(1e-5)
69
70/// 2-bit encode an ASCII base (non-ACGT -> 0).
71#[inline]
72fn base2bit(b: u8) -> u32 {
73    match b {
74        b'A' | b'a' => 0,
75        b'C' | b'c' => 1,
76        b'G' | b'g' => 2,
77        b'T' | b't' => 3,
78        _ => 0,
79    }
80}
81
82#[inline]
83fn complement_bit(x: u32) -> u32 {
84    3 - x // A<->T (0<->3), C<->G (1<->2)
85}
86
87/// The sequence-specific bias Markov model.
88#[derive(Debug, Clone)]
89pub struct SBModel {
90    /// log (after [`normalize`](Self::normalize)) or linear (before) transition
91    /// probabilities, laid out position-major: `probs[pos * ROWS + idx]`. Before
92    /// `normalize` this is materialized from the integer `probs_fp`.
93    probs: Vec<f64>,
94    /// Fixed-point integer accumulator for the transition counts (`weight *
95    /// BIAS_WEIGHT_SCALE`, truncated), summed order-independently across worker
96    /// threads and materialized into `probs` (plus the `PRIOR`) at `normalize`.
97    probs_fp: Vec<u64>,
98    /// per-position base marginals: `marginals[pos * 4 + base]`
99    marginals: Vec<f64>,
100    shifts: [u32; CONTEXT_LENGTH],
101    masks: [u32; CONTEXT_LENGTH],
102    trained: bool,
103}
104
105impl Default for SBModel {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl SBModel {
112    pub fn new() -> Self {
113        let mut shifts = [0u32; CONTEXT_LENGTH];
114        let mut masks = [0u32; CONTEXT_LENGTH];
115        for i in 0..CONTEXT_LENGTH {
116            // base i occupies the high bits; isolate the (order+1)-mer ending at i
117            shifts[i] = (2 * CONTEXT_LENGTH as u32) - 2 * (i as u32 + 1);
118            let width = 2 * (ORDER[i] + 1);
119            masks[i] = (1u32 << width) - 1;
120        }
121        Self {
122            probs: vec![PRIOR; ROWS * CONTEXT_LENGTH],
123            probs_fp: vec![0u64; ROWS * CONTEXT_LENGTH],
124            marginals: vec![PRIOR; 4 * CONTEXT_LENGTH],
125            shifts,
126            masks,
127            trained: false,
128        }
129    }
130
131    /// Encode a 9-base context (`CONTEXT_LENGTH` bytes) into a 2-bit-per-base
132    /// integer with base 0 in the high bits. `rev_comp` reverse-complements it.
133    fn encode(context: &[u8], rev_comp: bool) -> u32 {
134        debug_assert_eq!(context.len(), CONTEXT_LENGTH);
135        let mut mer = 0u32;
136        if rev_comp {
137            // reverse complement: last base becomes first
138            for &b in context.iter().rev() {
139                mer = (mer << 2) | complement_bit(base2bit(b));
140            }
141        } else {
142            for &b in context {
143                mer = (mer << 2) | base2bit(b);
144            }
145        }
146        mer
147    }
148
149    #[inline]
150    fn index_at(&self, mer: u32, pos: usize) -> usize {
151        ((mer >> self.shifts[pos]) & self.masks[pos]) as usize
152    }
153
154    /// The flattened transition table (`probs[pos * ROWS + idx]`), for dumping to
155    /// the aux bias files. Linear counts before [`normalize`](Self::normalize),
156    /// conditional log-probabilities after.
157    pub fn dump(&self) -> &[f64] {
158        &self.probs
159    }
160
161    /// Accumulate one observed context with the given weight.
162    pub fn add_context(&mut self, context: &[u8], rev_comp: bool, weight: f64) {
163        debug_assert!(!self.trained, "cannot add to a normalized model");
164        let mer = Self::encode(context, rev_comp);
165        let w = crate::bias_mass_to_fp(weight);
166        for pos in 0..CONTEXT_LENGTH {
167            let idx = self.index_at(mer, pos);
168            self.probs_fp[pos * ROWS + idx] += w;
169        }
170    }
171
172    /// Convert accumulated counts into conditional log-probabilities. Idempotent
173    /// guard: a model can only be normalized once.
174    pub fn normalize(&mut self) {
175        if self.trained {
176            return;
177        }
178        // Materialize the integer counts into `probs`, reintroducing the `PRIOR`
179        // pseudocount in f64 (matching the pre-fixed-point `probs = PRIOR + Σw`).
180        for (p, &fp) in self.probs.iter_mut().zip(&self.probs_fp) {
181            *p = PRIOR + fp as f64 / crate::BIAS_WEIGHT_SCALE;
182        }
183        for pos in 0..CONTEXT_LENGTH {
184            let num_states = 4usize.pow(ORDER[pos]);
185            for s in 0..num_states {
186                let node = s * 4;
187                let base = pos * ROWS + node;
188                let tot: f64 = self.probs[base..base + 4].iter().sum();
189                if tot > 0.0 {
190                    for j in 0..4 {
191                        self.probs[base + j] /= tot;
192                        self.marginals[pos * 4 + j] += self.probs[base + j];
193                    }
194                }
195            }
196            for j in 0..4 {
197                self.marginals[pos * 4 + j] /= num_states as f64;
198            }
199        }
200        for p in &mut self.probs {
201            *p = if *p > 0.0 { p.ln() } else { LOG_SMALL };
202        }
203        self.trained = true;
204    }
205
206    /// Log-probability the (normalized) model assigns to a context.
207    pub fn evaluate_log(&self, context: &[u8], rev_comp: bool) -> f64 {
208        debug_assert!(self.trained, "evaluate_log requires a normalized model");
209        let mer = Self::encode(context, rev_comp);
210        let mut lp = 0.0;
211        for pos in 0..CONTEXT_LENGTH {
212            let idx = self.index_at(mer, pos);
213            lp += self.probs[pos * ROWS + idx];
214        }
215        lp
216    }
217
218    pub fn is_trained(&self) -> bool {
219        self.trained
220    }
221
222    /// Add another (un-normalized) model's counts into this one. Both must be
223    /// pre-normalization; used to merge per-thread observed models.
224    pub fn combine_counts(&mut self, other: &SBModel) {
225        debug_assert!(!self.trained && !other.trained, "combine before normalize");
226        // Integer sum of the raw counts (the PRIOR is reintroduced once, in f64,
227        // at `normalize`), so the merge is order/thread-count independent.
228        for (a, b) in self.probs_fp.iter_mut().zip(&other.probs_fp) {
229            *a += *b;
230        }
231    }
232}
233
234/// Reverse-complement a DNA byte slice (ACGT; other bases map to `A`).
235pub(crate) fn revcomp_bytes(seq: &[u8]) -> Vec<u8> {
236    seq.iter()
237        .rev()
238        .map(|&b| match b {
239            b'A' | b'a' => b'T',
240            b'C' | b'c' => b'G',
241            b'G' | b'g' => b'C',
242            b'T' | b't' => b'A',
243            _ => b'A',
244        })
245        .collect()
246}
247
248/// Minimum transcript abundance to contribute to / be corrected by the bias
249/// background (salmon's `minAlpha`).
250pub(crate) const MIN_ALPHA: f64 = 1e-8;
251/// Minimum reliable CDF mass for a transcript (salmon's `minCDFMass`).
252pub(crate) const MIN_CDF_MASS: f64 = 1e-10;
253/// Fragment-length sampling stride in the effective-length convolution
254/// (salmon's `pdfSampFactor` = `biasSpeedSamp` default).
255pub const FLD_SAMP_STRIDE: usize = 5;
256
257/// Linear cumulative fragment-length distribution plus the `[low, high]`
258/// fragment-length quantile bounds (0.5% / 99.5%), mirroring the `cdf`,
259/// `fldLow`, `fldHigh` salmon computes in `updateEffectiveLengths`.
260pub fn fld_cdf_and_bounds(pmf_lin: &[f64]) -> (Vec<f64>, usize, usize) {
261    let mut cdf = vec![0.0f64; pmf_lin.len()];
262    let mut acc = 0.0;
263    let (mut lo, mut hi) = (0usize, 1usize);
264    let (mut lb, mut ub) = (false, false);
265    for i in 0..pmf_lin.len() {
266        acc += pmf_lin[i];
267        cdf[i] = acc;
268        if !lb && acc >= 0.005 {
269            lb = true;
270            lo = i;
271        }
272        if !ub && acc >= 0.995 {
273            ub = true;
274            hi = i;
275        }
276    }
277    (cdf, lo, hi)
278}
279
280/// Per-transcript conditional fragment-length CDF: salmon's
281/// `conditionalCDF(x) = (x > cdfMaxArg) ? 1.0 : cdf[x] / cdfMaxVal`, where
282/// `cdfMaxArg = min(cdf.len()-1, refLen)` normalizes the FLD to the fragment
283/// lengths that fit in this transcript.
284#[inline]
285pub(crate) fn conditional_cdf(cdf: &[f64], cdf_max_arg: usize, cdf_max_val: f64, x: i32) -> f64 {
286    if x > cdf_max_arg as i32 {
287        1.0
288    } else if x <= 0 {
289        cdf[0] / cdf_max_val
290    } else {
291        cdf[x as usize] / cdf_max_val
292    }
293}
294
295/// Build the expected forward/RC sequence-bias models by sliding the context
296/// window over each expressed transcript. Each context is weighted by the
297/// transcript's abundance density (`alpha / effLen`) times the conditional FLD
298/// mass that can start there (`conditionalCDF(maxFragLen)`), matching salmon's
299/// expected-model construction in `updateEffectiveLengths`.
300pub fn build_expected<'a, F>(
301    num_targets: usize,
302    seq_of: F,
303    alphas: &[f64],
304    eff_lens: &[f64],
305    cdf: &[f64],
306) -> (SBModel, SBModel)
307where
308    F: Fn(usize) -> &'a [u8] + Sync,
309{
310    use rayon::prelude::*;
311    let k = CONTEXT_LENGTH;
312    let cu = CONTEXT_LEFT as i32;
313    // Each expressed transcript contributes independently to the expected
314    // forward/RC context counts, an O(refLen) sweep per transcript. salmon
315    // parallelizes this over transcripts; do the same with rayon (per-thread
316    // `SBModel` partials reduced via `combine_counts`). `seq_of` must be `Sync`
317    // to share across threads (it is: a closure over the index). `num_targets`
318    // excludes decoys (the contiguous tail): decoys are never expressed and so
319    // contribute nothing, but skipping them outright guarantees no O(refLen)
320    // decoy sweep can ever run.
321    let per_tid = |tid: usize| -> Option<(SBModel, SBModel)> {
322        if alphas[tid] < MIN_ALPHA || eff_lens[tid] <= 0.0 {
323            return None;
324        }
325        let seq = seq_of(tid);
326        let ref_len = seq.len();
327        if ref_len < k {
328            return None;
329        }
330        let cdf_max_arg = (cdf.len() - 1).min(ref_len);
331        let cdf_max_val = cdf[cdf_max_arg];
332        if cdf_max_val < MIN_CDF_MASS {
333            return None;
334        }
335        let weight = alphas[tid] / eff_lens[tid];
336        let rc = revcomp_bytes(seq);
337        let mut fw = SBModel::new();
338        let mut rc_m = SBModel::new();
339        // fragStartPos in 0..(refLen - K) (salmon's loop bound)
340        for frag_start in 0..(ref_len - k) {
341            let max_frag_len = ref_len as i32 - (frag_start as i32 + cu);
342            if max_frag_len >= 0 && (max_frag_len as usize) < ref_len {
343                let cdensity = conditional_cdf(cdf, cdf_max_arg, cdf_max_val, max_frag_len);
344                let w = weight * cdensity;
345                fw.add_context(&seq[frag_start..frag_start + k], false, w);
346                rc_m.add_context(&rc[frag_start..frag_start + k], false, w);
347            }
348        }
349        Some((fw, rc_m))
350    };
351    let (mut exp_fw, mut exp_rc) = (0..num_targets)
352        .into_par_iter()
353        .fold(
354            || (SBModel::new(), SBModel::new()),
355            |mut acc, tid| {
356                if let Some((fw, rc_m)) = per_tid(tid) {
357                    acc.0.combine_counts(&fw);
358                    acc.1.combine_counts(&rc_m);
359                }
360                acc
361            },
362        )
363        .reduce(
364            || (SBModel::new(), SBModel::new()),
365            |mut a, b| {
366                a.0.combine_counts(&b.0);
367                a.1.combine_counts(&b.1);
368                a
369            },
370        );
371    exp_fw.normalize();
372    exp_rc.normalize();
373    (exp_fw, exp_rc)
374}
375
376/// Bias-corrected effective length of one transcript, matching salmon's
377/// `updateEffectiveLengths` (`src/util/SalmonUtils.cpp`).
378///
379/// `cdf` is the linear cumulative FLD; `fld_low`/`fld_high` the 0.5%/99.5%
380/// fragment-length quantiles (from [`fld_cdf_and_bounds`]). `elen` is the
381/// transcript's *unbiased* effective length (used for the lower barrier and the
382/// `unprocessedLen` guard). `stride` subsamples fragment lengths
383/// ([`FLD_SAMP_STRIDE`] matches salmon).
384///
385/// Per-position 5'/3' bias factors `exp(obsLog − expLog)` are placed at the
386/// fragment *read-start* (`fragStart + contextBefore`), the 3' factors reversed
387/// to forward fragment-end coordinates, then convolved with the conditional FLD:
388/// `effLen = Σ_l flWeight(l) · Σ_s fw[s]·rc[s+l−1]`. The result is floored at
389/// `min(elen, max(1, unprocessedLen))` (salmon's lower "barrier"; there is no
390/// upper cap, so a strongly-biased transcript's effLen can exceed its length).
391#[allow(clippy::too_many_arguments)]
392pub fn corrected_effective_length(
393    seq: &[u8],
394    cdf: &[f64],
395    fld_low: usize,
396    fld_high: usize,
397    obs_fw: &SBModel,
398    exp_fw: &SBModel,
399    obs_rc: &SBModel,
400    exp_rc: &SBModel,
401    elen: f64,
402    stride: usize,
403) -> f64 {
404    let k = CONTEXT_LENGTH;
405    let cu = CONTEXT_LEFT; // contextBefore(false)
406    let ref_len = seq.len();
407    let unprocessed = (ref_len as i32 - elen as i32).max(0);
408    let cdf_max_arg = (cdf.len() - 1).min(ref_len);
409    let cdf_max_val = cdf[cdf_max_arg];
410    if ref_len < k || unprocessed <= 0 || cdf_max_val < MIN_CDF_MASS {
411        return elen;
412    }
413    let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
414
415    // Per-position 5' and 3' sequence-bias factors, placed at the read-start.
416    let rc_seq = revcomp_bytes(seq);
417    let mut fw = vec![1.0f64; ref_len];
418    let mut rc = vec![1.0f64; ref_len];
419    for frag_start in 0..(ref_len - k) {
420        let read_start = frag_start + cu;
421        if read_start < ref_len {
422            fw[read_start] =
423                log_bias(obs_fw, exp_fw, &seq[frag_start..frag_start + k], false).exp();
424            rc[read_start] =
425                log_bias(obs_rc, exp_rc, &rc_seq[frag_start..frag_start + k], false).exp();
426        }
427    }
428    rc.reverse(); // align RC factors with forward fragment-end coordinates
429
430    // Convolve the bias factors with the conditional FLD over [fld_low, fld_high].
431    let stride = stride.max(1) as i32;
432    let max_len = (ref_len as i32).min(fld_high as i32 + 1);
433    let mut fl = fld_low as i32;
434    let mut done = fl >= max_len;
435    let sp = if fl > 0 { fl - 1 } else { 0 };
436    let mut prev_mass = cond(sp);
437    let mut eff = 0.0f64;
438    while !done {
439        if fl >= max_len {
440            done = true;
441            fl = max_len - 1;
442        }
443        let fl_weight = cond(fl) - prev_mass;
444        prev_mass = cond(fl);
445        let mut mass = 0.0f64;
446        let mut kstart = 0i32;
447        while kstart < ref_len as i32 - fl {
448            let frag_start = kstart as usize;
449            let frag_end = (kstart + fl - 1) as usize;
450            if frag_end < ref_len {
451                mass += fw[frag_start] * rc[frag_end];
452            } else {
453                break;
454            }
455            kstart += 1;
456        }
457        eff += fl_weight * mass;
458        fl += stride;
459    }
460
461    // Lower barrier (salmon default; no upper cap).
462    let offset = (unprocessed as f64).max(1.0);
463    eff.max(elen.min(offset))
464}
465
466/// Bias-corrected effective length when the per-fragment factor is **separable**
467/// as `a[start]·b[end]`. This holds for any combination of sequence and
468/// positional bias (each contributes an independent 5′ start factor and 3′ end
469/// factor); GC bias is *not* separable (its windowed-GC binning couples start
470/// and length) and stays on the scalar convolution.
471///
472/// The length sweep `mass(fl) = Σ_k a[k]·b[k+fl-1]` is then the cross-correlation
473/// of `a` and `b` evaluated at every lag, computed once via [`xcorr_fft`] in
474/// `O(L log L)` instead of `O(L · n_len)`. `a`/`b` must both have length
475/// `ref_len`; `cond` is the conditional fragment-length CMF; `unprocessed` is
476/// `max(0, ref_len − elen)`. Mirrors the scalar loop's `stride` (biasSpeedSamp)
477/// sampling and boundary exclusion exactly, so it is a drop-in replacement.
478#[allow(clippy::too_many_arguments)]
479pub fn eff_len_from_xcorr(
480    a: &[f64],
481    b: &[f64],
482    cond: impl Fn(i32) -> f64,
483    fld_low: usize,
484    fld_high: usize,
485    elen: f64,
486    unprocessed: i32,
487    stride: usize,
488    no_length_threshold: bool,
489) -> f64 {
490    let ref_len = a.len();
491    debug_assert_eq!(ref_len, b.len());
492    let max_len = (ref_len as i32).min(fld_high as i32 + 1);
493    if (fld_low as i32) >= max_len {
494        let offset = (unprocessed as f64).max(1.0);
495        return elen.max(elen.min(offset));
496    }
497    // Lags needed: Δ = fl-1 for fl in [fld_low, max_len). The scalar inner loop
498    // stops at kstart < ref_len-fl (so frag_end ≤ ref_len-2), i.e. it excludes
499    // the single fragment ending at ref_len-1; the zero-padded xcorr includes it
500    // (term a[ref_len-fl]·b[ref_len-1]), so subtract that for exact parity.
501    let max_lag = (max_len - 2).max(0) as usize;
502    let xc = xcorr_fft(a, b, max_lag);
503    let b_last = b[ref_len - 1];
504
505    // Mirror the scalar loop's `stride` (biasSpeedSamp) sampling EXACTLY so this
506    // is a drop-in faster replacement, not an accuracy change: same fragment
507    // lengths, same FLD weights. `xc[fl-1] - boundary` is the scalar's inner
508    // position sum (the boundary term is the one fragment ending at ref_len-1
509    // that the scalar's `kstart < ref_len-fl` bound excludes).
510    let stride = stride.max(1) as i32;
511    let mut eff = 0.0f64;
512    let mut fl = fld_low as i32;
513    let mut done = fl >= max_len;
514    let sp = if fl > 0 { fl - 1 } else { 0 };
515    let mut prev_mass = cond(sp);
516    while !done {
517        if fl >= max_len {
518            done = true;
519            fl = max_len - 1;
520        }
521        let fl_weight = cond(fl) - prev_mass;
522        prev_mass = cond(fl);
523        if fl >= 1 {
524            let delta = (fl - 1) as usize;
525            let boundary = a[(ref_len as i32 - fl) as usize] * b_last;
526            eff += fl_weight * (xc[delta] - boundary);
527        }
528        fl += stride;
529    }
530    if no_length_threshold {
531        if eff > 1.0 {
532            eff
533        } else {
534            elen
535        }
536    } else {
537        let offset = (unprocessed as f64).max(1.0);
538        eff.max(elen.min(offset))
539    }
540}
541
542/// FFT form of [`corrected_effective_length`] (sequence-only): builds the 5′/3′
543/// sequence factor arrays, then evaluates the length sweep as their
544/// cross-correlation via [`eff_len_from_xcorr`]. Kept as the validated
545/// seq-only reference (the combined no-GC path in [`crate::bias`] builds the
546/// same factors, optionally fused with positional bias, and calls the same
547/// core). Numerically identical to [`corrected_effective_length`] up to FFT
548/// round-off.
549#[allow(clippy::too_many_arguments)]
550pub fn corrected_effective_length_fft(
551    seq: &[u8],
552    cdf: &[f64],
553    fld_low: usize,
554    fld_high: usize,
555    obs_fw: &SBModel,
556    exp_fw: &SBModel,
557    obs_rc: &SBModel,
558    exp_rc: &SBModel,
559    elen: f64,
560    stride: usize,
561    no_length_threshold: bool,
562) -> f64 {
563    let k = CONTEXT_LENGTH;
564    let cu = CONTEXT_LEFT;
565    let ref_len = seq.len();
566    let unprocessed = (ref_len as i32 - elen as i32).max(0);
567    let cdf_max_arg = (cdf.len() - 1).min(ref_len);
568    let cdf_max_val = cdf[cdf_max_arg];
569    if ref_len < k || unprocessed <= 0 || cdf_max_val < MIN_CDF_MASS {
570        return elen;
571    }
572    let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
573
574    let rc_seq = revcomp_bytes(seq);
575    let mut fw = vec![1.0f64; ref_len];
576    let mut rc = vec![1.0f64; ref_len];
577    for frag_start in 0..(ref_len - k) {
578        let read_start = frag_start + cu;
579        if read_start < ref_len {
580            fw[read_start] =
581                log_bias(obs_fw, exp_fw, &seq[frag_start..frag_start + k], false).exp();
582            rc[read_start] =
583                log_bias(obs_rc, exp_rc, &rc_seq[frag_start..frag_start + k], false).exp();
584        }
585    }
586    rc.reverse();
587
588    eff_len_from_xcorr(
589        &fw,
590        &rc,
591        cond,
592        fld_low,
593        fld_high,
594        elen,
595        unprocessed,
596        stride,
597        no_length_threshold,
598    )
599}
600
601/// Log bias of `observed` relative to `expected` for a context:
602/// `log P_obs(context) - log P_exp(context)`. The fragment-level bias weight is
603/// `exp` of this.
604pub fn log_bias(observed: &SBModel, expected: &SBModel, context: &[u8], rev_comp: bool) -> f64 {
605    observed.evaluate_log(context, rev_comp) - expected.evaluate_log(context, rev_comp)
606}
607
608/// Precomputed `observed − expected` log-transition table for a fixed model
609/// pair. The effective-length correction evaluates `log_bias` for a context at
610/// every transcript position; `log_bias` evaluates BOTH models (each
611/// re-encoding the context and sweeping all `CONTEXT_LENGTH` positions), so a
612/// context costs two encodes + two table sweeps. Folding the pair into a single
613/// difference table `diff[pos·ROWS+idx] = obs − exp` (built once per quant run,
614/// the models being fixed during correction) collapses that to **one** encode +
615/// **one** sweep — ~1.4× on the per-position factor build, the dominant cost of
616/// the seqBias sweep.
617///
618/// `eval` equals `log_bias(obs, exp, ctx, rc)` up to floating-point
619/// reassociation: it sums `Σ(obs−exp)` rather than `(Σobs) − (Σexp)`, a
620/// difference of ~1e-15 per context (machine epsilon), far below quant-output
621/// resolution.
622pub struct LogBiasTable {
623    diff: Vec<f64>,
624    shifts: [u32; CONTEXT_LENGTH],
625    masks: [u32; CONTEXT_LENGTH],
626}
627
628impl LogBiasTable {
629    /// Build the difference table from a normalized observed/expected pair.
630    pub fn new(observed: &SBModel, expected: &SBModel) -> Self {
631        debug_assert!(observed.trained && expected.trained);
632        let diff = observed
633            .probs
634            .iter()
635            .zip(&expected.probs)
636            .map(|(&o, &e)| o - e)
637            .collect();
638        Self {
639            diff,
640            shifts: observed.shifts,
641            masks: observed.masks,
642        }
643    }
644
645    /// `log_bias` for a context (one encode, one table sweep).
646    #[inline]
647    pub fn eval(&self, context: &[u8], rev_comp: bool) -> f64 {
648        let mer = SBModel::encode(context, rev_comp);
649        let mut lp = 0.0;
650        for pos in 0..CONTEXT_LENGTH {
651            let idx = ((mer >> self.shifts[pos]) & self.masks[pos]) as usize;
652            lp += self.diff[pos * ROWS + idx];
653        }
654        lp
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    // Build a realistic-ish trained obs/exp pair: expected from a sweep of the
663    // transcript, observed = expected with a few enriched contexts.
664    fn trained_pair(seq: &[u8]) -> (SBModel, SBModel) {
665        let rc = revcomp_bytes(seq);
666        let mut exp = SBModel::new();
667        for p in 0..=(seq.len() - CONTEXT_LENGTH) {
668            exp.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
669            exp.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
670        }
671        let mut obs = exp.clone();
672        for _ in 0..500 {
673            obs.add_context(b"AAACCCGGG", false, 1.0);
674            obs.add_context(b"TTTGGGCCC", true, 1.0);
675        }
676        obs.normalize();
677        exp.normalize();
678        (obs, exp)
679    }
680
681    #[test]
682    #[ignore = "profiling bench; run with --ignored --nocapture"]
683    fn bench_factor_build() {
684        use std::time::Instant;
685        let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGTTTAGCGATCG";
686        let seq: Vec<u8> = (0..2000)
687            .map(|i| bases[(i * 7 + 3) % bases.len()])
688            .collect();
689        let (obs, exp) = trained_pair(&seq);
690        let rc_seq = revcomp_bytes(&seq);
691        let k = CONTEXT_LENGTH;
692        let n = seq.len() - k;
693        let iters = 8000usize; // ~16M positions, real-workload scale
694
695        // V0: current path — log_bias (two evaluate_log, each re-encodes) + exp.
696        let t = Instant::now();
697        let mut acc = 0.0f64;
698        for _ in 0..iters {
699            for fs in 0..n {
700                acc += log_bias(&obs, &exp, &seq[fs..fs + k], false).exp();
701                acc += log_bias(&obs, &exp, &rc_seq[fs..fs + k], false).exp();
702            }
703        }
704        let v0 = t.elapsed().as_secs_f64();
705
706        // No-exp: V0 minus the exp() to isolate exp cost.
707        let t = Instant::now();
708        let mut acc1 = 0.0f64;
709        for _ in 0..iters {
710            for fs in 0..n {
711                acc1 += log_bias(&obs, &exp, &seq[fs..fs + k], false);
712                acc1 += log_bias(&obs, &exp, &rc_seq[fs..fs + k], false);
713            }
714        }
715        let v_noexp = t.elapsed().as_secs_f64();
716
717        // Encode-only: isolate the encode cost (2 encodes per position as today).
718        let t = Instant::now();
719        let mut enc = 0u64;
720        for _ in 0..iters {
721            for fs in 0..n {
722                enc ^= SBModel::encode(&seq[fs..fs + k], false) as u64;
723                enc ^= SBModel::encode(&rc_seq[fs..fs + k], false) as u64;
724            }
725        }
726        let v_enc = t.elapsed().as_secs_f64();
727
728        // V1: encode ONCE per context, evaluate obs and exp from the shared mer
729        // (byte-identical: encode is deterministic, sum order unchanged).
730        let eval_mer = |m: &SBModel, mer: u32| -> f64 {
731            let mut lp = 0.0;
732            for pos in 0..CONTEXT_LENGTH {
733                lp += m.probs[pos * ROWS + m.index_at(mer, pos)];
734            }
735            lp
736        };
737        let t = Instant::now();
738        let mut acc_v1 = 0.0f64;
739        let mut max_d1 = 0.0f64;
740        for it in 0..iters {
741            for fs in 0..n {
742                let mf = SBModel::encode(&seq[fs..fs + k], false);
743                let mr = SBModel::encode(&rc_seq[fs..fs + k], false);
744                let bf = (eval_mer(&obs, mf) - eval_mer(&exp, mf)).exp();
745                let br = (eval_mer(&obs, mr) - eval_mer(&exp, mr)).exp();
746                acc_v1 += bf + br;
747                if it == 0 {
748                    let rf = log_bias(&obs, &exp, &seq[fs..fs + k], false).exp();
749                    let rr = log_bias(&obs, &exp, &rc_seq[fs..fs + k], false).exp();
750                    max_d1 = max_d1.max((bf - rf).abs()).max((br - rr).abs());
751                }
752            }
753        }
754        let v1 = t.elapsed().as_secs_f64();
755
756        // V2: precomputed diff table d[pos*ROWS+idx] = obs - exp (one eval per
757        // direction). NON-byte-identical (Σ(a-b) vs Σa-Σb reassociation).
758        let mut diff = vec![0.0f64; ROWS * CONTEXT_LENGTH];
759        for (i, d) in diff.iter_mut().enumerate() {
760            *d = obs.probs[i] - exp.probs[i];
761        }
762        let eval_diff = |mer: u32| -> f64 {
763            let mut lp = 0.0;
764            for pos in 0..CONTEXT_LENGTH {
765                lp += diff[pos * ROWS + obs.index_at(mer, pos)];
766            }
767            lp
768        };
769        let t = Instant::now();
770        let mut acc_v2 = 0.0f64;
771        let mut max_d2 = 0.0f64;
772        for it in 0..iters {
773            for fs in 0..n {
774                let bf = eval_diff(SBModel::encode(&seq[fs..fs + k], false)).exp();
775                let br = eval_diff(SBModel::encode(&rc_seq[fs..fs + k], false)).exp();
776                acc_v2 += bf + br;
777                if it == 0 {
778                    let rf = log_bias(&obs, &exp, &seq[fs..fs + k], false).exp();
779                    let rr = log_bias(&obs, &exp, &rc_seq[fs..fs + k], false).exp();
780                    max_d2 = max_d2.max((bf - rf).abs()).max((br - rr).abs());
781                }
782            }
783        }
784        let v2 = t.elapsed().as_secs_f64();
785
786        eprintln!("--- factor-build bench ({iters} iters x {n} pos x2) ---");
787        eprintln!("V0 current (log_bias+exp)   : {v0:.3}s   acc={acc:.3}");
788        eprintln!(
789            "  no-exp (log_bias only)    : {v_noexp:.3}s  acc={acc1:.3}  => exp cost ~{:.3}s",
790            v0 - v_noexp
791        );
792        eprintln!("  encode-only (2x/pos)      : {v_enc:.3}s   enc={enc}");
793        eprintln!("V1 encode-once (byte-ident) : {v1:.3}s   acc={acc_v1:.3}  max|Δ|={max_d1:.3e}  speedup={:.2}x", v0 / v1);
794        eprintln!("V2 diff-table (reassoc)     : {v2:.3}s   acc={acc_v2:.3}  max|Δ|={max_d2:.3e}  speedup={:.2}x", v0 / v2);
795    }
796
797    #[test]
798    fn uniform_contexts_give_near_zero_bias() {
799        // Both models trained on the same uniform set of contexts -> bias ~ 0.
800        let ctxs: Vec<Vec<u8>> = (0..256)
801            .map(|i| {
802                let bases = b"ACGT";
803                (0..CONTEXT_LENGTH)
804                    .map(|p| bases[((i >> (p * 2)) & 3) as usize])
805                    .collect()
806            })
807            .collect();
808        let mut obs = SBModel::new();
809        let mut exp = SBModel::new();
810        for c in &ctxs {
811            obs.add_context(c, false, 1.0);
812            exp.add_context(c, false, 1.0);
813        }
814        obs.normalize();
815        exp.normalize();
816        for c in &ctxs {
817            assert!(log_bias(&obs, &exp, c, false).abs() < 1e-9);
818        }
819    }
820
821    #[test]
822    fn enriched_context_has_positive_bias() {
823        // observed enriched for a specific context vs a uniform expected model
824        let target: Vec<u8> = b"ACGTACGTA".to_vec();
825        let bases = b"ACGT";
826        let uniform: Vec<Vec<u8>> = (0..4096)
827            .map(|i| {
828                (0..CONTEXT_LENGTH)
829                    .map(|p| bases[((i >> (p * 2)) & 3) as usize])
830                    .collect()
831            })
832            .collect();
833
834        let mut exp = SBModel::new();
835        for c in &uniform {
836            exp.add_context(c, false, 1.0);
837        }
838        exp.normalize();
839
840        let mut obs = SBModel::new();
841        for c in &uniform {
842            obs.add_context(c, false, 1.0);
843        }
844        for _ in 0..5000 {
845            obs.add_context(&target, false, 1.0); // enrich
846        }
847        obs.normalize();
848
849        assert!(
850            log_bias(&obs, &exp, &target, false) > 0.5,
851            "enriched context should have positive log-bias"
852        );
853    }
854
855    #[test]
856    fn unbiased_correction_reduces_to_standard_eff_len() {
857        // obs == exp -> all bias factors 1 -> corrected effLen == standard
858        // effLen = sum_l pmf(l)*(refLen - l). Point-mass FLD at l=100.
859        let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGT";
860        let seq: Vec<u8> = (0..400).map(|i| bases[i % bases.len()]).collect();
861        let mut m = SBModel::new();
862        let rc = revcomp_bytes(&seq);
863        for p in 0..=(seq.len() - CONTEXT_LENGTH) {
864            m.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
865            m.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
866        }
867        let mut obs = m.clone();
868        let mut exp = m.clone();
869        obs.normalize();
870        exp.normalize();
871
872        let mut pmf = vec![0.0; 200];
873        pmf[100] = 1.0;
874        let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
875        // unbiased effLen at point-mass 100 on a 400nt transcript = 400 - 100 = 300
876        let eff = corrected_effective_length(&seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 300.0, 1);
877        assert!((eff - 300.0).abs() < 1e-6, "got {eff}");
878    }
879
880    #[test]
881    fn fft_matches_exact_scalar_corrected_eff_len() {
882        // Build a genuinely biased obs/exp pair (so per-position factors != 1),
883        // a spread FLD, and check the FFT cross-correlation form equals the exact
884        // (stride=1) scalar convolution up to FFT round-off.
885        let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGTTTAGCGATCG";
886        let seq: Vec<u8> = (0..1500)
887            .map(|i| bases[(i * 7 + 3) % bases.len()])
888            .collect();
889        let rc = revcomp_bytes(&seq);
890        let mut exp = SBModel::new();
891        for p in 0..=(seq.len() - CONTEXT_LENGTH) {
892            exp.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
893            exp.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
894        }
895        let mut obs = exp.clone();
896        // enrich a couple of contexts so obs != exp
897        let t1 = b"AAACCCGGG";
898        let t2 = b"TTTGGGCCC";
899        for _ in 0..500 {
900            obs.add_context(t1, false, 1.0);
901            obs.add_context(t2, true, 1.0);
902        }
903        obs.normalize();
904        exp.normalize();
905
906        // spread FLD (Gaussian-ish around 250)
907        let mut pmf = vec![0.0f64; 600];
908        for (l, v) in pmf.iter_mut().enumerate() {
909            let d = l as f64 - 250.0;
910            *v = (-d * d / (2.0 * 40.0 * 40.0)).exp();
911        }
912        let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
913
914        // FFT must match the scalar at the SAME stride (drop-in, not an accuracy
915        // change) — check both the exact (stride=1) and strided (stride=5) cases.
916        for stride in [1usize, 5] {
917            let scalar = corrected_effective_length(
918                &seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 1200.0, stride,
919            );
920            let fft = corrected_effective_length_fft(
921                &seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 1200.0, stride, false,
922            );
923            let rel = (scalar - fft).abs() / scalar.abs();
924            assert!(
925                rel < 1e-9,
926                "FFT vs scalar mismatch at stride={stride}: scalar={scalar} fft={fft} rel={rel:.3e}"
927            );
928        }
929    }
930
931    #[test]
932    fn eff_len_from_xcorr_matches_scalar_combined_factors() {
933        // The generic core handles ANY separable per-fragment factor
934        // a[start]·b[end] — i.e. seq-only, pos-only, or seq+pos (GC is not
935        // separable). Validate it against an explicit scalar double-loop on
936        // arbitrary positive factor arrays (a stand-in for seqFW·posFW etc.),
937        // at both stride 1 and 5, so the pos / seq+pos dispatch in `bias.rs` is
938        // covered independently of how the factors were built.
939        let ref_len = 1300usize;
940        let a: Vec<f64> = (0..ref_len)
941            .map(|i| 0.5 + 1.5 * ((i as f64 * 0.013).sin() * 0.5 + 0.5))
942            .collect();
943        let b: Vec<f64> = (0..ref_len)
944            .map(|i| 0.4 + 1.8 * ((i as f64 * 0.021 + 1.0).cos() * 0.5 + 0.5))
945            .collect();
946
947        let mut pmf = vec![0.0f64; 600];
948        for (l, v) in pmf.iter_mut().enumerate() {
949            let d = l as f64 - 250.0;
950            *v = (-d * d / (2.0 * 40.0 * 40.0)).exp();
951        }
952        let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
953        let elen = 1100.0f64;
954        let unprocessed = (ref_len as i32 - elen as i32).max(0);
955        let cdf_max_arg = (cdf.len() - 1).min(ref_len);
956        let cdf_max_val = cdf[cdf_max_arg];
957        let cond = |x: i32| conditional_cdf(&cdf, cdf_max_arg, cdf_max_val, x);
958
959        for stride in [1usize, 5] {
960            // Explicit scalar reference: same fragment lengths/weights and the
961            // same `kstart < ref_len - fl` bound as the combined scalar loop.
962            let max_len = (ref_len as i32).min(hi as i32 + 1);
963            let st = stride.max(1) as i32;
964            let mut fl = lo as i32;
965            let mut done = fl >= max_len;
966            let sp = if fl > 0 { fl - 1 } else { 0 };
967            let mut prev = cond(sp);
968            let mut eff = 0.0f64;
969            while !done {
970                if fl >= max_len {
971                    done = true;
972                    fl = max_len - 1;
973                }
974                let w = cond(fl) - prev;
975                prev = cond(fl);
976                let kmax = ref_len as i32 - fl;
977                let mut mass = 0.0f64;
978                let mut k = 0i32;
979                while k < kmax {
980                    mass += a[k as usize] * b[(k + fl - 1) as usize];
981                    k += 1;
982                }
983                eff += w * mass;
984                fl += st;
985            }
986            let offset = (unprocessed as f64).max(1.0);
987            let scalar = eff.max(elen.min(offset));
988
989            let fft = eff_len_from_xcorr(&a, &b, cond, lo, hi, elen, unprocessed, stride, false);
990            let rel = (scalar - fft).abs() / scalar.abs();
991            assert!(
992                rel < 1e-9,
993                "combined-factor FFT vs scalar mismatch at stride={stride}: scalar={scalar} fft={fft} rel={rel:.3e}"
994            );
995        }
996    }
997
998    #[test]
999    fn revcomp_encoding_is_consistent() {
1000        // RC of a context evaluated forward equals the context evaluated as RC.
1001        let ctx: Vec<u8> = b"ACGTACGTA".to_vec();
1002        let rc: Vec<u8> = ctx
1003            .iter()
1004            .rev()
1005            .map(|&b| match b {
1006                b'A' => b'T',
1007                b'C' => b'G',
1008                b'G' => b'C',
1009                b'T' => b'A',
1010                x => x,
1011            })
1012            .collect();
1013        assert_eq!(SBModel::encode(&ctx, true), SBModel::encode(&rc, false));
1014    }
1015
1016    #[test]
1017    fn build_expected_respects_num_targets_bound() {
1018        // Five real transcripts plus a sixth "decoy" with a very distinctive
1019        // composition (poly-AC). The decoy must influence the expected model only
1020        // when `num_targets` includes it, and must be skipped (cheaply) when its
1021        // alpha is zero — the two ways decoys are kept out of the bias models.
1022        let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGT";
1023        let mut refs: Vec<Vec<u8>> = (0..5)
1024            .map(|s| (0..200).map(|i| bases[(i + s) % bases.len()]).collect())
1025            .collect();
1026        refs.push(
1027            (0..400)
1028                .map(|i| if i % 2 == 0 { b'A' } else { b'C' })
1029                .collect(),
1030        );
1031        let num_refs = refs.len();
1032        let alphas = vec![1.0; num_refs];
1033        let eff_lens = vec![150.0; num_refs];
1034        let mut pmf = vec![0.0; 200];
1035        pmf[100] = 1.0;
1036        let (cdf, _lo, _hi) = fld_cdf_and_bounds(&pmf);
1037
1038        // Exclude the decoy (num_targets = 5) vs include it (num_targets = 6).
1039        let (a_fw, _) = build_expected(5, |t| refs[t].as_slice(), &alphas, &eff_lens, &cdf);
1040        let (b_fw, _) = build_expected(6, |t| refs[t].as_slice(), &alphas, &eff_lens, &cdf);
1041        assert!(a_fw.is_trained() && b_fw.is_trained());
1042        assert!(a_fw.dump().iter().all(|v| v.is_finite()));
1043        let diff: f64 = a_fw
1044            .dump()
1045            .iter()
1046            .zip(b_fw.dump())
1047            .map(|(x, y)| (x - y).abs())
1048            .sum();
1049        assert!(
1050            diff > 1e-6,
1051            "a target beyond num_targets must not contribute (diff={diff})"
1052        );
1053
1054        // With the decoy's alpha zeroed the MIN_ALPHA guard skips it, so including
1055        // it (num_targets = 6) must match excluding it (num_targets = 5).
1056        let mut alphas0 = alphas.clone();
1057        alphas0[5] = 0.0;
1058        let (c_fw, _) = build_expected(6, |t| refs[t].as_slice(), &alphas0, &eff_lens, &cdf);
1059        let diff2: f64 = a_fw
1060            .dump()
1061            .iter()
1062            .zip(c_fw.dump())
1063            .map(|(x, y)| (x - y).abs())
1064            .sum();
1065        assert!(
1066            diff2 < 1e-9,
1067            "zero-alpha target must not contribute (diff={diff2})"
1068        );
1069    }
1070}