1pub const DEFAULT_COND_BINS: usize = 3;
20pub const DEFAULT_GC_BINS: usize = 25;
23pub const GC_MAX_RATIO: f64 = 1000.0;
25const NORM_PRIOR: f64 = 0.1;
27
28#[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#[derive(Debug, Clone)]
43pub struct GcFragModel {
44 cond_bins: usize,
45 gc_bins: usize,
46 counts: Vec<f64>,
50 counts_fp: Vec<u64>,
54 normalized: bool,
55 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 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 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 pub fn dump(&self) -> &[f64] {
103 &self.counts
104 }
105
106 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 #[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 pub fn combine_counts(&mut self, other: &GcFragModel) {
122 debug_assert!(!self.normalized && !other.normalized);
123 for (a, b) in self.counts_fp.iter_mut().zip(&other.counts_fp) {
126 *a += *b;
127 }
128 }
129
130 pub fn normalize(&mut self) {
133 if self.normalized {
134 return;
135 }
136 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
156pub 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; out
178}
179
180use crate::seqbias::{
185 conditional_cdf, log_bias, revcomp_bytes, SBModel, CONTEXT_LEFT, CONTEXT_LENGTH, MIN_ALPHA,
186 MIN_CDF_MASS,
187};
188
189pub const GC_SAMP_STRIDE: usize = 5;
192
193const OUTSIDE_5P: i32 = 4; const OUTSIDE_3P: i32 = 3; const INSIDE_5P: i32 = 1; const INSIDE_3P: i32 = 2; #[inline]
211fn lrint(x: f64) -> i32 {
212 #[cfg(target_arch = "x86_64")]
213 {
214 #[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
228pub 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#[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#[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
260pub struct GcRank {
266 rank: sux::rank_sel::Rank9,
267}
268
269impl GcRank {
270 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 #[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#[derive(Clone, Copy)]
303pub enum GcView<'a> {
304 Dense(&'a [u32]),
305 Rank {
306 rank: &'a sux::rank_sel::Rank9,
307 off: usize,
308 base: usize,
310 len: usize,
311 },
312}
313
314impl GcView<'_> {
315 #[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 #[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#[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 #[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#[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
410pub struct GcContext {
421 cum: Vec<u32>, fp_count: Vec<i64>, fp_wlen: Vec<i32>, tp_count: Vec<i64>, tp_wlen: Vec<i32>, }
427
428impl GcContext {
429 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 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 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 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 #[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#[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 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#[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 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 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 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 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); }
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); }
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 let seq = b"ACGTACGTAC"; let p = gc_prefix(seq);
754 assert_eq!(gc_frac(&p, 0, 9), 50);
755 let seq2 = b"GGGGCCCC";
757 let p2 = gc_prefix(seq2);
758 assert_eq!(gc_frac(&p2, 0, 7), 100);
759 assert_eq!(gc_frac(&p, 2, 2), 100); assert_eq!(gc_frac(&p, 0, 0), 0); }
763
764 #[test]
765 fn gc_desc_matches_frag_and_context() {
766 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 assert_eq!(ff, 50, "fragFrac");
772 assert!((0..=100).contains(&cf), "contextFrac in range: {cf}");
773 assert_eq!(ff, gc_frac(&p, 10, 29));
775 }
776
777 #[test]
778 fn idx_lut_matches_bin_frac() {
779 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 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 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 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 let mut seq = vec![b'A'; 60];
850 for i in 17..=21 {
851 seq[i] = b'G'; }
853 for i in 39..=43 {
854 seq[i] = b'C'; }
856 let p = gc_prefix(&seq);
857 let (_ff, cf) = gc_desc(&GcView::Dense(&p), 20, 40).unwrap();
858 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); exp.inc(75, 0, 1e6); 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}