1const ORDER: [u32; 9] = [0, 1, 2, 2, 2, 2, 2, 2, 2];
17pub const CONTEXT_LENGTH: usize = 9;
19pub const CONTEXT_LEFT: usize = 3;
21pub const CONTEXT_RIGHT: usize = 5;
23const ROWS: usize = 64;
25const PRIOR: f64 = 1e-10;
27const LOG_SMALL: f64 = -11.512_925_464_970_229; #[inline]
32fn base2bit(b: u8) -> u32 {
33 match b {
34 b'A' | b'a' => 0,
35 b'C' | b'c' => 1,
36 b'G' | b'g' => 2,
37 b'T' | b't' => 3,
38 _ => 0,
39 }
40}
41
42#[inline]
43fn complement_bit(x: u32) -> u32 {
44 3 - x }
46
47#[derive(Debug, Clone)]
49pub struct SBModel {
50 probs: Vec<f64>,
53 marginals: Vec<f64>,
55 shifts: [u32; CONTEXT_LENGTH],
56 masks: [u32; CONTEXT_LENGTH],
57 trained: bool,
58}
59
60impl Default for SBModel {
61 fn default() -> Self {
62 Self::new()
63 }
64}
65
66impl SBModel {
67 pub fn new() -> Self {
68 let mut shifts = [0u32; CONTEXT_LENGTH];
69 let mut masks = [0u32; CONTEXT_LENGTH];
70 for i in 0..CONTEXT_LENGTH {
71 shifts[i] = (2 * CONTEXT_LENGTH as u32) - 2 * (i as u32 + 1);
73 let width = 2 * (ORDER[i] + 1);
74 masks[i] = (1u32 << width) - 1;
75 }
76 Self {
77 probs: vec![PRIOR; ROWS * CONTEXT_LENGTH],
78 marginals: vec![PRIOR; 4 * CONTEXT_LENGTH],
79 shifts,
80 masks,
81 trained: false,
82 }
83 }
84
85 fn encode(context: &[u8], rev_comp: bool) -> u32 {
88 debug_assert_eq!(context.len(), CONTEXT_LENGTH);
89 let mut mer = 0u32;
90 if rev_comp {
91 for &b in context.iter().rev() {
93 mer = (mer << 2) | complement_bit(base2bit(b));
94 }
95 } else {
96 for &b in context {
97 mer = (mer << 2) | base2bit(b);
98 }
99 }
100 mer
101 }
102
103 #[inline]
104 fn index_at(&self, mer: u32, pos: usize) -> usize {
105 ((mer >> self.shifts[pos]) & self.masks[pos]) as usize
106 }
107
108 pub fn dump(&self) -> &[f64] {
112 &self.probs
113 }
114
115 pub fn add_context(&mut self, context: &[u8], rev_comp: bool, weight: f64) {
117 debug_assert!(!self.trained, "cannot add to a normalized model");
118 let mer = Self::encode(context, rev_comp);
119 for pos in 0..CONTEXT_LENGTH {
120 let idx = self.index_at(mer, pos);
121 self.probs[pos * ROWS + idx] += weight;
122 }
123 }
124
125 pub fn normalize(&mut self) {
128 if self.trained {
129 return;
130 }
131 for pos in 0..CONTEXT_LENGTH {
132 let num_states = 4usize.pow(ORDER[pos]);
133 for s in 0..num_states {
134 let node = s * 4;
135 let base = pos * ROWS + node;
136 let tot: f64 = self.probs[base..base + 4].iter().sum();
137 if tot > 0.0 {
138 for j in 0..4 {
139 self.probs[base + j] /= tot;
140 self.marginals[pos * 4 + j] += self.probs[base + j];
141 }
142 }
143 }
144 for j in 0..4 {
145 self.marginals[pos * 4 + j] /= num_states as f64;
146 }
147 }
148 for p in &mut self.probs {
149 *p = if *p > 0.0 { p.ln() } else { LOG_SMALL };
150 }
151 self.trained = true;
152 }
153
154 pub fn evaluate_log(&self, context: &[u8], rev_comp: bool) -> f64 {
156 debug_assert!(self.trained, "evaluate_log requires a normalized model");
157 let mer = Self::encode(context, rev_comp);
158 let mut lp = 0.0;
159 for pos in 0..CONTEXT_LENGTH {
160 let idx = self.index_at(mer, pos);
161 lp += self.probs[pos * ROWS + idx];
162 }
163 lp
164 }
165
166 pub fn is_trained(&self) -> bool {
167 self.trained
168 }
169
170 pub fn combine_counts(&mut self, other: &SBModel) {
173 debug_assert!(!self.trained && !other.trained, "combine before normalize");
174 for (a, b) in self.probs.iter_mut().zip(&other.probs) {
175 *a += *b - PRIOR; }
177 }
178}
179
180pub(crate) fn revcomp_bytes(seq: &[u8]) -> Vec<u8> {
182 seq.iter()
183 .rev()
184 .map(|&b| match b {
185 b'A' | b'a' => b'T',
186 b'C' | b'c' => b'G',
187 b'G' | b'g' => b'C',
188 b'T' | b't' => b'A',
189 _ => b'A',
190 })
191 .collect()
192}
193
194pub(crate) const MIN_ALPHA: f64 = 1e-8;
197pub(crate) const MIN_CDF_MASS: f64 = 1e-10;
199pub const FLD_SAMP_STRIDE: usize = 5;
202
203pub fn fld_cdf_and_bounds(pmf_lin: &[f64]) -> (Vec<f64>, usize, usize) {
207 let mut cdf = vec![0.0f64; pmf_lin.len()];
208 let mut acc = 0.0;
209 let (mut lo, mut hi) = (0usize, 1usize);
210 let (mut lb, mut ub) = (false, false);
211 for i in 0..pmf_lin.len() {
212 acc += pmf_lin[i];
213 cdf[i] = acc;
214 if !lb && acc >= 0.005 {
215 lb = true;
216 lo = i;
217 }
218 if !ub && acc >= 0.995 {
219 ub = true;
220 hi = i;
221 }
222 }
223 (cdf, lo, hi)
224}
225
226#[inline]
231pub(crate) fn conditional_cdf(cdf: &[f64], cdf_max_arg: usize, cdf_max_val: f64, x: i32) -> f64 {
232 if x > cdf_max_arg as i32 {
233 1.0
234 } else if x <= 0 {
235 cdf[0] / cdf_max_val
236 } else {
237 cdf[x as usize] / cdf_max_val
238 }
239}
240
241pub fn build_expected<'a, F>(
247 num_targets: usize,
248 seq_of: F,
249 alphas: &[f64],
250 eff_lens: &[f64],
251 cdf: &[f64],
252) -> (SBModel, SBModel)
253where
254 F: Fn(usize) -> &'a [u8] + Sync,
255{
256 use rayon::prelude::*;
257 let k = CONTEXT_LENGTH;
258 let cu = CONTEXT_LEFT as i32;
259 let per_tid = |tid: usize| -> Option<(SBModel, SBModel)> {
268 if alphas[tid] < MIN_ALPHA || eff_lens[tid] <= 0.0 {
269 return None;
270 }
271 let seq = seq_of(tid);
272 let ref_len = seq.len();
273 if ref_len < k {
274 return None;
275 }
276 let cdf_max_arg = (cdf.len() - 1).min(ref_len);
277 let cdf_max_val = cdf[cdf_max_arg];
278 if cdf_max_val < MIN_CDF_MASS {
279 return None;
280 }
281 let weight = alphas[tid] / eff_lens[tid];
282 let rc = revcomp_bytes(seq);
283 let mut fw = SBModel::new();
284 let mut rc_m = SBModel::new();
285 for frag_start in 0..(ref_len - k) {
287 let max_frag_len = ref_len as i32 - (frag_start as i32 + cu);
288 if max_frag_len >= 0 && (max_frag_len as usize) < ref_len {
289 let cdensity = conditional_cdf(cdf, cdf_max_arg, cdf_max_val, max_frag_len);
290 let w = weight * cdensity;
291 fw.add_context(&seq[frag_start..frag_start + k], false, w);
292 rc_m.add_context(&rc[frag_start..frag_start + k], false, w);
293 }
294 }
295 Some((fw, rc_m))
296 };
297 let (mut exp_fw, mut exp_rc) = (0..num_targets)
298 .into_par_iter()
299 .fold(
300 || (SBModel::new(), SBModel::new()),
301 |mut acc, tid| {
302 if let Some((fw, rc_m)) = per_tid(tid) {
303 acc.0.combine_counts(&fw);
304 acc.1.combine_counts(&rc_m);
305 }
306 acc
307 },
308 )
309 .reduce(
310 || (SBModel::new(), SBModel::new()),
311 |mut a, b| {
312 a.0.combine_counts(&b.0);
313 a.1.combine_counts(&b.1);
314 a
315 },
316 );
317 exp_fw.normalize();
318 exp_rc.normalize();
319 (exp_fw, exp_rc)
320}
321
322#[allow(clippy::too_many_arguments)]
338pub fn corrected_effective_length(
339 seq: &[u8],
340 cdf: &[f64],
341 fld_low: usize,
342 fld_high: usize,
343 obs_fw: &SBModel,
344 exp_fw: &SBModel,
345 obs_rc: &SBModel,
346 exp_rc: &SBModel,
347 elen: f64,
348 stride: usize,
349) -> f64 {
350 let k = CONTEXT_LENGTH;
351 let cu = CONTEXT_LEFT; let ref_len = seq.len();
353 let unprocessed = (ref_len as i32 - elen as i32).max(0);
354 let cdf_max_arg = (cdf.len() - 1).min(ref_len);
355 let cdf_max_val = cdf[cdf_max_arg];
356 if ref_len < k || unprocessed <= 0 || cdf_max_val < MIN_CDF_MASS {
357 return elen;
358 }
359 let cond = |x: i32| conditional_cdf(cdf, cdf_max_arg, cdf_max_val, x);
360
361 let rc_seq = revcomp_bytes(seq);
363 let mut fw = vec![1.0f64; ref_len];
364 let mut rc = vec![1.0f64; ref_len];
365 for frag_start in 0..(ref_len - k) {
366 let read_start = frag_start + cu;
367 if read_start < ref_len {
368 fw[read_start] =
369 log_bias(obs_fw, exp_fw, &seq[frag_start..frag_start + k], false).exp();
370 rc[read_start] =
371 log_bias(obs_rc, exp_rc, &rc_seq[frag_start..frag_start + k], false).exp();
372 }
373 }
374 rc.reverse(); let stride = stride.max(1) as i32;
378 let max_len = (ref_len as i32).min(fld_high as i32 + 1);
379 let mut fl = fld_low as i32;
380 let mut done = fl >= max_len;
381 let sp = if fl > 0 { fl - 1 } else { 0 };
382 let mut prev_mass = cond(sp);
383 let mut eff = 0.0f64;
384 while !done {
385 if fl >= max_len {
386 done = true;
387 fl = max_len - 1;
388 }
389 let fl_weight = cond(fl) - prev_mass;
390 prev_mass = cond(fl);
391 let mut mass = 0.0f64;
392 let mut kstart = 0i32;
393 while kstart < ref_len as i32 - fl {
394 let frag_start = kstart as usize;
395 let frag_end = (kstart + fl - 1) as usize;
396 if frag_end < ref_len {
397 mass += fw[frag_start] * rc[frag_end];
398 } else {
399 break;
400 }
401 kstart += 1;
402 }
403 eff += fl_weight * mass;
404 fl += stride;
405 }
406
407 let offset = (unprocessed as f64).max(1.0);
409 eff.max(elen.min(offset))
410}
411
412pub fn log_bias(observed: &SBModel, expected: &SBModel, context: &[u8], rev_comp: bool) -> f64 {
416 observed.evaluate_log(context, rev_comp) - expected.evaluate_log(context, rev_comp)
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn uniform_contexts_give_near_zero_bias() {
425 let ctxs: Vec<Vec<u8>> = (0..256)
427 .map(|i| {
428 let bases = b"ACGT";
429 (0..CONTEXT_LENGTH)
430 .map(|p| bases[((i >> (p * 2)) & 3) as usize])
431 .collect()
432 })
433 .collect();
434 let mut obs = SBModel::new();
435 let mut exp = SBModel::new();
436 for c in &ctxs {
437 obs.add_context(c, false, 1.0);
438 exp.add_context(c, false, 1.0);
439 }
440 obs.normalize();
441 exp.normalize();
442 for c in &ctxs {
443 assert!(log_bias(&obs, &exp, c, false).abs() < 1e-9);
444 }
445 }
446
447 #[test]
448 fn enriched_context_has_positive_bias() {
449 let target: Vec<u8> = b"ACGTACGTA".to_vec();
451 let bases = b"ACGT";
452 let uniform: Vec<Vec<u8>> = (0..4096)
453 .map(|i| {
454 (0..CONTEXT_LENGTH)
455 .map(|p| bases[((i >> (p * 2)) & 3) as usize])
456 .collect()
457 })
458 .collect();
459
460 let mut exp = SBModel::new();
461 for c in &uniform {
462 exp.add_context(c, false, 1.0);
463 }
464 exp.normalize();
465
466 let mut obs = SBModel::new();
467 for c in &uniform {
468 obs.add_context(c, false, 1.0);
469 }
470 for _ in 0..5000 {
471 obs.add_context(&target, false, 1.0); }
473 obs.normalize();
474
475 assert!(
476 log_bias(&obs, &exp, &target, false) > 0.5,
477 "enriched context should have positive log-bias"
478 );
479 }
480
481 #[test]
482 fn unbiased_correction_reduces_to_standard_eff_len() {
483 let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGT";
486 let seq: Vec<u8> = (0..400).map(|i| bases[i % bases.len()]).collect();
487 let mut m = SBModel::new();
488 let rc = revcomp_bytes(&seq);
489 for p in 0..=(seq.len() - CONTEXT_LENGTH) {
490 m.add_context(&seq[p..p + CONTEXT_LENGTH], false, 1.0);
491 m.add_context(&rc[p..p + CONTEXT_LENGTH], false, 1.0);
492 }
493 let mut obs = m.clone();
494 let mut exp = m.clone();
495 obs.normalize();
496 exp.normalize();
497
498 let mut pmf = vec![0.0; 200];
499 pmf[100] = 1.0;
500 let (cdf, lo, hi) = fld_cdf_and_bounds(&pmf);
501 let eff = corrected_effective_length(&seq, &cdf, lo, hi, &obs, &exp, &obs, &exp, 300.0, 1);
503 assert!((eff - 300.0).abs() < 1e-6, "got {eff}");
504 }
505
506 #[test]
507 fn revcomp_encoding_is_consistent() {
508 let ctx: Vec<u8> = b"ACGTACGTA".to_vec();
510 let rc: Vec<u8> = ctx
511 .iter()
512 .rev()
513 .map(|&b| match b {
514 b'A' => b'T',
515 b'C' => b'G',
516 b'G' => b'C',
517 b'T' => b'A',
518 x => x,
519 })
520 .collect();
521 assert_eq!(SBModel::encode(&ctx, true), SBModel::encode(&rc, false));
522 }
523
524 #[test]
525 fn build_expected_respects_num_targets_bound() {
526 let bases = b"ACGTACGTAGGCCTTAACCGGTTACGTACGT";
531 let mut refs: Vec<Vec<u8>> = (0..5)
532 .map(|s| (0..200).map(|i| bases[(i + s) % bases.len()]).collect())
533 .collect();
534 refs.push(
535 (0..400)
536 .map(|i| if i % 2 == 0 { b'A' } else { b'C' })
537 .collect(),
538 );
539 let num_refs = refs.len();
540 let alphas = vec![1.0; num_refs];
541 let eff_lens = vec![150.0; num_refs];
542 let mut pmf = vec![0.0; 200];
543 pmf[100] = 1.0;
544 let (cdf, _lo, _hi) = fld_cdf_and_bounds(&pmf);
545
546 let (a_fw, _) = build_expected(5, |t| refs[t].as_slice(), &alphas, &eff_lens, &cdf);
548 let (b_fw, _) = build_expected(6, |t| refs[t].as_slice(), &alphas, &eff_lens, &cdf);
549 assert!(a_fw.is_trained() && b_fw.is_trained());
550 assert!(a_fw.dump().iter().all(|v| v.is_finite()));
551 let diff: f64 = a_fw
552 .dump()
553 .iter()
554 .zip(b_fw.dump())
555 .map(|(x, y)| (x - y).abs())
556 .sum();
557 assert!(
558 diff > 1e-6,
559 "a target beyond num_targets must not contribute (diff={diff})"
560 );
561
562 let mut alphas0 = alphas.clone();
565 alphas0[5] = 0.0;
566 let (c_fw, _) = build_expected(6, |t| refs[t].as_slice(), &alphas0, &eff_lens, &cdf);
567 let diff2: f64 = a_fw
568 .dump()
569 .iter()
570 .zip(c_fw.dump())
571 .map(|(x, y)| (x - y).abs())
572 .sum();
573 assert!(
574 diff2 < 1e-9,
575 "zero-alpha target must not contribute (diff={diff2})"
576 );
577 }
578}