gwseq_io/genomic/bins.rs
1//! Binning and reduction.
2//!
3//! The two stringly-typed parameter families of the Python API — `bin_mode` and
4//! `reduce` — become enums with a `FromStr` whose error lists the accepted
5//! spellings.
6//!
7//! There are **two** accumulator types, not one:
8//! [`BinStats`] is what the binning hot loop carries (two `f64`s, one per bin
9//! of a whole-genome walk), and [`ValueStats`] is what a reduction needs
10//! (extremes and a sum of squares as well). Merging them would put five fields
11//! where the inner loop wants two.
12
13use crate::error::{Error, Result};
14
15/// How the values covering a bin become the bin's value.
16///
17/// All three are per *base* of the bin, not per record of the file — a
18/// distinction that matters for a bigWig whose intervals are wider than a bin,
19/// and for a bigBed, where a bin's value is the depth of coverage its entries
20/// make over that bin.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum BinMode {
23 /// Base-weighted mean.
24 #[default]
25 Mean,
26 /// Value summed over every base it covers.
27 Sum,
28 /// Bases of the bin carrying data — the bin's width where the file covers
29 /// it fully.
30 Count,
31}
32
33/// How a row of bins, or a column of loci, becomes one number.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum Reduce {
36 #[default]
37 Mean,
38 Sd,
39 Sem,
40 Sum,
41 Count,
42 Min,
43 Max,
44 L1Norm,
45 L2Norm,
46}
47
48impl std::str::FromStr for BinMode {
49 type Err = Error;
50 fn from_str(s: &str) -> Result<Self> {
51 match s {
52 "mean" => Ok(BinMode::Mean),
53 "sum" => Ok(BinMode::Sum),
54 "count" => Ok(BinMode::Count),
55 // The wording is API: callers match on this message.
56 o => Err(Error::invalid(format!("bin_mode {o} invalid"))),
57 }
58 }
59}
60
61impl std::str::FromStr for Reduce {
62 type Err = Error;
63 fn from_str(s: &str) -> Result<Self> {
64 match s {
65 "mean" => Ok(Reduce::Mean),
66 "sd" => Ok(Reduce::Sd),
67 "sem" => Ok(Reduce::Sem),
68 "sum" => Ok(Reduce::Sum),
69 "count" => Ok(Reduce::Count),
70 "min" => Ok(Reduce::Min),
71 "max" => Ok(Reduce::Max),
72 "l1norm" => Ok(Reduce::L1Norm),
73 "l2norm" => Ok(Reduce::L2Norm),
74 o => Err(Error::invalid(format!("reduce {o} invalid"))),
75 }
76 }
77}
78
79/// What one bin accumulates while it is being filled.
80///
81/// `f64` under an `f32` result: a bin of a whole-genome walk sums millions of
82/// values, and an `f32` accumulator stops making progress once the running
83/// total passes 2^24.
84///
85/// `count` is fractional rather than a record count. A bin is `span /
86/// bin_count` bases wide and need not be a whole number of them, and a record
87/// covers however many of those bases it overlaps.
88#[derive(Debug, Clone, Copy, Default, PartialEq)]
89pub struct BinStats {
90 pub sum: f64,
91 pub count: f64,
92}
93
94impl BinStats {
95 /// Fold in a value covering `bases` bases of this bin.
96 #[inline]
97 pub fn add(&mut self, value: f32, bases: f64) {
98 self.sum += value as f64 * bases;
99 self.count += bases;
100 }
101
102 #[inline]
103 pub fn merge(&mut self, other: &BinStats) {
104 self.sum += other.sum;
105 self.count += other.count;
106 }
107
108 /// The bin's value under `mode`.
109 ///
110 /// `Mean` divides by `count` without guarding it: a bin nothing reached has
111 /// `count == 0` and yields NaN, and the caller — which knows whether the
112 /// bin was covered — writes `def_value` there instead. Moving the guard
113 /// here would change which bins come back as `def_value`.
114 #[inline]
115 pub fn apply(&self, mode: BinMode) -> f32 {
116 match mode {
117 BinMode::Mean => (self.sum / self.count) as f32,
118 BinMode::Sum => self.sum as f32,
119 BinMode::Count => self.count as f32,
120 }
121 }
122
123 #[inline]
124 pub fn is_empty(&self) -> bool {
125 self.count == 0.0
126 }
127}
128
129/// What a reduction accumulates over a locus or a profile bin.
130///
131/// `min` and `max` are data values and stay `f32`; the accumulators do not.
132/// Both start NaN, so an untouched region reports no extremes rather than
133/// `±inf`.
134///
135/// # Why the sums are shifted
136///
137/// The obvious accumulator holds `Σx` and `Σx²` and computes the variance as
138/// `E[x²] − E[x]²`. That subtracts two nearly equal large numbers whenever the
139/// values sit far from zero relative to their spread, which is the ordinary
140/// case for coverage, CPM and log-ratio tracks — and it does not merely lose
141/// a few bits. Measured against a float64 reference on `normal(1e4, 1e-2)`
142/// data, the naive form reported a standard deviation of 1.9e-1 where the
143/// truth was 9.8e-3: **wrong by a factor of twenty**, and in another case
144/// wrong all the way to zero, the variance having gone negative and been
145/// clamped.
146///
147/// So every sum here is taken relative to `shift`, the first value folded in:
148/// `Σ(x − k)` and `Σ(x − k)²`. The variance is then
149/// `[Σ(x−k)² − Σ(x−k)²/n] / n` over numbers of the size of the *spread*
150/// rather than of the mean, and the cancellation goes with it. A constant
151/// column gives exactly zero, because every difference is exactly zero.
152/// `mean` and `sum` are recovered by adding `k` back, which costs one
153/// multiply and no accuracy.
154///
155/// This is the standard shifted-data algorithm. Welford's would be equivalent
156/// for values arriving one at a time, and cannot fold in a pre-aggregated zoom
157/// record, which this has to do — see [`ValueStats::add_aggregate`].
158///
159/// **Accumulation order is behaviour.** These are summed in the order the
160/// extraction visits intervals, which is block order within a batch and batch
161/// order across the output. That order is deterministic on purpose: it is what
162/// makes an answer independent of `parallel`, which `tests/roundtrip.rs` and
163/// `tests/properties.rs` both check.
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub struct ValueStats {
166 pub min: f32,
167 pub max: f32,
168 /// Every sum below is relative to this. Set by the first fold; meaningless
169 /// while `count` is 0.
170 shift: f64,
171 /// `Σ(xᵢ − shift)`, each value weighted by the bases it covers.
172 sum_shifted: f64,
173 /// `Σ(xᵢ − shift)²`, same weighting.
174 sum_sq_shifted: f64,
175 /// `Σ|xᵢ|`, same weighting. Only `L1Norm` reads it.
176 ///
177 /// Exact everywhere a value arrives with its own sign, which is every path
178 /// but one: a zoom record carries `Σx` and `Σx²` and no `Σ|x|`, so a
179 /// record straddling zero cannot supply it. See [`Self::add_aggregate`].
180 sum_abs: f64,
181 pub count: i64,
182}
183
184impl Default for ValueStats {
185 fn default() -> Self {
186 Self {
187 min: f32::NAN,
188 max: f32::NAN,
189 shift: 0.0,
190 sum_shifted: 0.0,
191 sum_sq_shifted: 0.0,
192 sum_abs: 0.0,
193 count: 0,
194 }
195 }
196}
197
198impl ValueStats {
199 /// `Σx`, undoing the shift.
200 #[inline]
201 pub fn sum(&self) -> f64 {
202 self.sum_shifted + self.shift * self.count as f64
203 }
204
205 /// `Σx²`, undoing the shift. Only `L2Norm` and the tests want this, and
206 /// it is the one quantity the shift makes *less* accurate — which is the
207 /// right trade, `l2norm` having no cancellation to suffer from.
208 #[inline]
209 pub fn sum_squared(&self) -> f64 {
210 let n = self.count as f64;
211 self.sum_sq_shifted + 2.0 * self.shift * self.sum_shifted + self.shift * self.shift * n
212 }
213
214 /// Fold in one value covering one base.
215 #[inline]
216 pub fn add(&mut self, value: f32) {
217 self.add_repeated(value, 1);
218 }
219
220 /// Fold in `bases` bases all carrying `value`, as a wide interval does.
221 ///
222 /// The `f32::min`/`max` NaN rule does the seeding: `NAN.min(x) == x`, so
223 /// the first value replaces the initial NaN without a branch.
224 #[inline]
225 pub fn add_repeated(&mut self, value: f32, bases: i64) {
226 if bases <= 0 {
227 return;
228 }
229 let v = value as f64;
230 if self.count == 0 {
231 self.shift = v;
232 }
233 self.min = self.min.min(value);
234 self.max = self.max.max(value);
235 let d = v - self.shift;
236 let n = bases as f64;
237 self.sum_shifted += d * n;
238 self.sum_sq_shifted += d * d * n;
239 self.sum_abs += v.abs() * n;
240 self.count += bases;
241 }
242
243 /// Fold in a pre-aggregated run: `bases` bases whose sum is `sum` and
244 /// whose sum of squares is `sum_squared`, with known extremes.
245 ///
246 /// This is how a **zoom record** enters, prorated over the part of it a
247 /// window covers. The record's own `Σx²` is used rather than its mean
248 /// squared: squaring the mean would keep only the variance *between*
249 /// records and drop the variance inside each, collapsing `sd` as the zoom
250 /// level rises.
251 ///
252 /// `Σ|x|` cannot be recovered from a record in general — the format stores
253 /// no such field — so it is derived where the record's sign is not in
254 /// doubt (`min ≥ 0`, or `max ≤ 0`, which is every non-negative track) and
255 /// approximated by `|Σx|` where the record straddles zero. That is a lower
256 /// bound, it is the only thing the format allows, and the README says so
257 /// under `quantify`'s `reduce`.
258 #[inline]
259 pub fn add_aggregate(&mut self, min: f32, max: f32, sum: f64, sum_squared: f64, bases: i64) {
260 if bases <= 0 {
261 return;
262 }
263 let n = bases as f64;
264 if self.count == 0 {
265 // The run's own mean: the shift that makes its internal spread the
266 // thing being summed.
267 self.shift = sum / n;
268 }
269 self.min = self.min.min(min);
270 self.max = self.max.max(max);
271 let k = self.shift;
272 // Σ(x−k) = Σx − nk, and Σ(x−k)² = Σx² − 2kΣx + nk².
273 self.sum_shifted += sum - n * k;
274 self.sum_sq_shifted += sum_squared - 2.0 * k * sum + n * k * k;
275 self.sum_abs += if min >= 0.0 {
276 sum
277 } else if max <= 0.0 {
278 -sum
279 } else {
280 sum.abs()
281 };
282 self.count += bases;
283 }
284
285 /// Fold another accumulator in, rebasing it onto this one's shift.
286 #[inline]
287 pub fn merge(&mut self, other: &ValueStats) {
288 if other.count == 0 {
289 return;
290 }
291 if self.count == 0 {
292 *self = *other;
293 return;
294 }
295 self.min = self.min.min(other.min);
296 self.max = self.max.max(other.max);
297 // Rebase `other` from its shift to this one. With d = k_other − k_self:
298 // Σ(x−k_self) = Σ(x−k_other) + n·d
299 // Σ(x−k_self)² = Σ(x−k_other)² + 2d·Σ(x−k_other) + n·d²
300 let d = other.shift - self.shift;
301 let n = other.count as f64;
302 self.sum_shifted += other.sum_shifted + n * d;
303 self.sum_sq_shifted += other.sum_sq_shifted + 2.0 * d * other.sum_shifted + n * d * d;
304 self.sum_abs += other.sum_abs;
305 self.count += other.count;
306 }
307
308 /// Population variance, from the shifted sums.
309 ///
310 /// Still clamped at zero, and now for a reason that is nearly theoretical
311 /// rather than routine: with the shift the subtraction is between numbers
312 /// of the size of the spread, so it goes negative only on a run that is
313 /// constant to the last bit — where zero is the right answer anyway.
314 fn variance(&self, count: f64) -> f64 {
315 let mean_shifted = self.sum_shifted / count;
316 ((self.sum_sq_shifted / count) - mean_shifted * mean_shifted).max(0.0)
317 }
318
319 /// Reduce to one number.
320 ///
321 /// A region no data reached has no mean, no extremes and no spread, so
322 /// those keep `def_value`. Its count is not unknown but **zero**: leaving
323 /// `def_value` there would make `count` the one reduction unable to say
324 /// "nothing here".
325 pub fn reduce(&self, reduce: Reduce, def_value: f32) -> f32 {
326 if self.count == 0 {
327 return match reduce {
328 Reduce::Count => 0.0,
329 _ => def_value,
330 };
331 }
332 let count = self.count as f64;
333 match reduce {
334 Reduce::Mean => (self.shift + self.sum_shifted / count) as f32,
335 Reduce::Sd => self.variance(count).sqrt() as f32,
336 Reduce::Sem => (self.variance(count) / count).sqrt() as f32,
337 Reduce::Sum => self.sum() as f32,
338 Reduce::Count => count as f32,
339 Reduce::Min => self.min,
340 Reduce::Max => self.max,
341 // Σ|x|, which is what an L1 norm is. Not Σx: those agree only on
342 // data that never goes negative, and the reductions a caller
343 // reaches for an L1 norm over are the ones that do.
344 Reduce::L1Norm => self.sum_abs as f32,
345 Reduce::L2Norm => self.sum_squared().max(0.0).sqrt() as f32,
346 }
347 }
348}
349
350/// The bin grid a request resolves to: how wide a bin is, how many there are,
351/// and whether a partial trailing bin is walked.
352///
353/// `bin_size` is `f64` because the API accepts a fractional one — it snaps the
354/// window to a grid of that width, so the edges no longer fall on whole bases.
355/// The `iter_all_*` paths reject a fractional size, the windows there having to
356/// tile the genome on a whole-base grid.
357#[derive(Debug, Clone, Copy)]
358pub struct BinPlan {
359 pub bin_size: f64,
360 pub bin_count: Option<usize>,
361 pub full_bin: bool,
362}
363
364impl BinPlan {
365 pub fn new(bin_size: f64, bin_count: Option<usize>, full_bin: bool) -> Result<Self> {
366 // is_finite first, so NaN is rejected before it reaches a comparison
367 // that would answer false either way.
368 if !bin_size.is_finite() || bin_size <= 0.0 {
369 return Err(Error::invalid(format!(
370 "bin_size must be a positive finite number, got {bin_size}"
371 )));
372 }
373 // A bin is a run of bases, so its width is a whole number of them.
374 // A fractional one used to be accepted and snapped the window to a
375 // grid finer than a base, which put bin edges between bases and made
376 // every value a weighted split of two — an answer no caller asked for
377 // and one the whole-file iterators refused outright, so the API
378 // disagreed with itself. `bin_count` is how to ask for a window
379 // divided into a number of parts.
380 if bin_size.fract() != 0.0 {
381 return Err(Error::invalid(format!(
382 "bin_size must be a whole number of base pairs, got {bin_size}. \
383 Use bin_count to divide a window into a fixed number of bins."
384 )));
385 }
386 if bin_count == Some(0) {
387 return Err(Error::invalid("bin_count must be at least 1"));
388 }
389 Ok(Self {
390 bin_size,
391 bin_count,
392 full_bin,
393 })
394 }
395
396 /// The bin size as the whole number of base pairs it is.
397 ///
398 /// Infallible: [`BinPlan::new`] is the only constructor and it refuses a
399 /// fractional one.
400 pub fn whole_bin_size(&self) -> i64 {
401 self.bin_size as i64
402 }
403}
404
405impl Default for BinPlan {
406 fn default() -> Self {
407 Self {
408 bin_size: 1.0,
409 bin_count: None,
410 full_bin: false,
411 }
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn bin_stats_weight_by_bases() {
421 let mut s = BinStats::default();
422 s.add(2.0, 10.0);
423 s.add(4.0, 30.0);
424 assert_eq!(s.count, 40.0);
425 assert_eq!(s.sum, 140.0);
426 assert_eq!(s.apply(BinMode::Mean), 3.5);
427 assert_eq!(s.apply(BinMode::Sum), 140.0);
428 assert_eq!(s.apply(BinMode::Count), 40.0);
429 }
430
431 #[test]
432 fn an_untouched_bin_means_nan_not_zero() {
433 // The caller substitutes def_value; this must not do it silently.
434 assert!(BinStats::default().apply(BinMode::Mean).is_nan());
435 assert_eq!(BinStats::default().apply(BinMode::Count), 0.0);
436 }
437
438 #[test]
439 fn value_stats_seed_extremes_from_nan() {
440 let mut s = ValueStats::default();
441 assert!(s.min.is_nan() && s.max.is_nan());
442 s.add(3.0);
443 s.add(-1.0);
444 s.add(7.0);
445 assert_eq!((s.min, s.max, s.count), (-1.0, 7.0, 3));
446 assert_eq!(s.sum(), 9.0);
447 assert_eq!(s.sum_squared(), 59.0);
448 }
449
450 /// The L1 norm is `Σ|x|`. It agrees with the sum only on data that never
451 /// goes negative, and the data a caller reaches for an L1 norm over is
452 /// exactly the data that does.
453 #[test]
454 fn l1norm_is_the_sum_of_absolute_values() {
455 let mut s = ValueStats::default();
456 for v in [3.0f32, -4.0, 5.0, -6.0] {
457 s.add(v);
458 }
459 assert_eq!(s.reduce(Reduce::Sum, 0.0), -2.0);
460 assert_eq!(s.reduce(Reduce::L1Norm, 0.0), 18.0);
461 assert_eq!(s.reduce(Reduce::L2Norm, 0.0), (86.0f32).sqrt());
462 }
463
464 /// The whole point of the shift. `E[x²] − E[x]²` on this data subtracts
465 /// two numbers that agree to fifteen digits; the shifted form subtracts
466 /// two that agree to none.
467 #[test]
468 fn a_tiny_spread_on_a_large_mean_survives() {
469 let mean = 1.0e4f32;
470 let values: Vec<f32> = (0..2000)
471 .map(|i| mean + (i % 7) as f32 * 1.0e-2 - 0.03)
472 .collect();
473 let mut s = ValueStats::default();
474 for v in &values {
475 s.add(*v);
476 }
477 // The reference, in f64 and two passes.
478 let n = values.len() as f64;
479 let m: f64 = values.iter().map(|v| *v as f64).sum::<f64>() / n;
480 let want = (values.iter().map(|v| (*v as f64 - m).powi(2)).sum::<f64>() / n).sqrt();
481 let got = s.reduce(Reduce::Sd, 0.0) as f64;
482 assert!((got - want).abs() <= want * 1e-3, "sd {got} against {want}");
483 }
484
485 /// A column that never varies has a standard deviation of exactly zero,
486 /// not "nearly zero" and not a clamped negative.
487 #[test]
488 fn a_constant_column_has_no_spread_at_all() {
489 for value in [1.0f32, 12345.678, -9876.5, 1.0e-7] {
490 let mut s = ValueStats::default();
491 for _ in 0..500 {
492 s.add(value);
493 }
494 assert_eq!(s.reduce(Reduce::Sd, -1.0), 0.0, "value {value}");
495 assert_eq!(s.reduce(Reduce::Sem, -1.0), 0.0, "value {value}");
496 assert_eq!(s.reduce(Reduce::Mean, -1.0), value, "value {value}");
497 }
498 }
499
500 /// Merging two accumulators rebases one onto the other's shift, and has to
501 /// give what folding everything into one would have given.
502 #[test]
503 fn merging_is_folding_by_another_route() {
504 let a_values = [1000.0f32, 1000.5, 999.5, 1001.0];
505 let b_values = [-3.0f32, 2000.25, 7.5];
506 let (mut a, mut b, mut whole) = (
507 ValueStats::default(),
508 ValueStats::default(),
509 ValueStats::default(),
510 );
511 for v in a_values {
512 a.add(v);
513 whole.add(v);
514 }
515 for v in b_values {
516 b.add(v);
517 whole.add(v);
518 }
519 a.merge(&b);
520 assert_eq!(a.count, whole.count);
521 for r in [
522 Reduce::Mean,
523 Reduce::Sum,
524 Reduce::L1Norm,
525 Reduce::Min,
526 Reduce::Max,
527 ] {
528 assert_eq!(a.reduce(r, 0.0), whole.reduce(r, 0.0), "{r:?}");
529 }
530 let (got, want) = (a.reduce(Reduce::Sd, 0.0), whole.reduce(Reduce::Sd, 0.0));
531 assert!((got - want).abs() <= want * 1e-5, "sd {got} against {want}");
532 }
533
534 /// A pre-aggregated run — a zoom record — carries no `Σ|x|`, so the L1
535 /// norm is derived from its sign where the sign is not in doubt.
536 #[test]
537 fn an_aggregate_run_folds_in_with_its_own_spread() {
538 // 100 bases summing to 500 with a sum of squares of 3000: mean 5,
539 // variance 3000/100 - 25 = 5.
540 let mut s = ValueStats::default();
541 s.add_aggregate(1.0, 9.0, 500.0, 3000.0, 100);
542 assert_eq!(s.count, 100);
543 assert_eq!(s.reduce(Reduce::Mean, 0.0), 5.0);
544 assert_eq!(s.reduce(Reduce::Sum, 0.0), 500.0);
545 assert!((s.reduce(Reduce::Sd, 0.0) - 5.0f32.sqrt()).abs() < 1e-4);
546 // min >= 0, so every value in the run is positive and Σ|x| == Σx.
547 assert_eq!(s.reduce(Reduce::L1Norm, 0.0), 500.0);
548
549 // All-negative: Σ|x| == -Σx, equally certain.
550 let mut s = ValueStats::default();
551 s.add_aggregate(-9.0, -1.0, -500.0, 3000.0, 100);
552 assert_eq!(s.reduce(Reduce::L1Norm, 0.0), 500.0);
553 }
554
555 #[test]
556 fn empty_reduces_to_def_value_except_count() {
557 let s = ValueStats::default();
558 for r in [
559 Reduce::Mean,
560 Reduce::Sd,
561 Reduce::Sum,
562 Reduce::Min,
563 Reduce::Max,
564 ] {
565 assert_eq!(s.reduce(r, -5.0), -5.0, "{r:?}");
566 }
567 assert_eq!(s.reduce(Reduce::Count, -5.0), 0.0);
568 }
569
570 #[test]
571 fn reductions_match_their_definitions() {
572 let mut s = ValueStats::default();
573 for v in [2.0f32, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0] {
574 s.add(v);
575 }
576 assert_eq!(s.reduce(Reduce::Mean, 0.0), 5.0);
577 assert_eq!(s.reduce(Reduce::Sd, 0.0), 2.0); // textbook population sd
578 assert_eq!(s.reduce(Reduce::Sum, 0.0), 40.0);
579 assert_eq!(s.reduce(Reduce::Count, 0.0), 8.0);
580 assert_eq!(s.reduce(Reduce::Min, 0.0), 2.0);
581 assert_eq!(s.reduce(Reduce::Max, 0.0), 9.0);
582 // Every value here is positive, so the L1 norm and the sum agree.
583 assert_eq!(s.reduce(Reduce::L1Norm, 0.0), 40.0);
584 assert_eq!(s.reduce(Reduce::L2Norm, 0.0), 232.0f32.sqrt());
585 }
586
587 #[test]
588 fn variance_of_a_constant_column_is_not_negative() {
589 let mut s = ValueStats::default();
590 for _ in 0..1000 {
591 s.add(1e7);
592 }
593 assert_eq!(s.reduce(Reduce::Sd, 0.0), 0.0);
594 }
595
596 #[test]
597 fn add_repeated_matches_repeated_add() {
598 let mut a = ValueStats::default();
599 for _ in 0..5 {
600 a.add(3.5);
601 }
602 let mut b = ValueStats::default();
603 b.add_repeated(3.5, 5);
604 assert_eq!(a, b);
605 }
606
607 #[test]
608 fn bin_plan_rejects_nonsense() {
609 assert!(BinPlan::new(0.0, None, false).is_err());
610 assert!(BinPlan::new(-1.0, None, false).is_err());
611 assert!(BinPlan::new(f64::NAN, None, false).is_err());
612 assert!(BinPlan::new(1.0, Some(0), false).is_err());
613 assert_eq!(
614 BinPlan::new(10.0, None, false).unwrap().whole_bin_size(),
615 10
616 );
617 }
618
619 /// A bin is a run of bases. A fractional width used to be accepted here
620 /// and refused by the whole-file iterators, so the same argument was legal
621 /// through one door and not the other.
622 #[test]
623 fn a_fractional_bin_size_is_refused_everywhere() {
624 for bad in [0.5f64, 2.5, 1.000001, 99.9] {
625 let err = BinPlan::new(bad, None, false).unwrap_err().to_string();
626 assert!(err.contains("whole number of base pairs"), "{bad}: {err}");
627 assert!(err.contains("bin_count"), "{bad}: {err}");
628 }
629 // A whole number written as a float is fine; that is what the Python
630 // layer hands over for `bin_size=100`.
631 for good in [1.0f64, 100.0, 1e6] {
632 assert!(BinPlan::new(good, None, false).is_ok(), "{good}");
633 }
634 }
635
636 #[test]
637 fn mode_and_reduce_parse_and_reject() {
638 use std::str::FromStr;
639 assert_eq!(BinMode::from_str("sum").unwrap(), BinMode::Sum);
640 assert_eq!(Reduce::from_str("l2norm").unwrap(), Reduce::L2Norm);
641 let err = BinMode::from_str("median").unwrap_err().to_string();
642 assert_eq!(err, "bin_mode median invalid");
643 assert_eq!(
644 Reduce::from_str("median").unwrap_err().to_string(),
645 "reduce median invalid"
646 );
647 }
648}