Skip to main content

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}