Skip to main content

gwseq_io/
arrays.rs

1//! Array shaping shared by more than one format.
2//!
3//! The two bilinear resizes behind `exact_bin_count`, and
4//! `compress_sparse_by_color`, which the module exposes as a free function.
5//!
6//! Hand-written rather than reached for from an image crate, because the
7//! sample positions, the edge behaviour *and the arithmetic the four corners
8//! are combined with* decide the output, and a matrix of contacts is not a
9//! photograph — a resized cell is a number someone will do statistics on.
10//!
11//! Two things about that arithmetic are deliberate and neither is obvious:
12//!
13//! - The interpolation is grouped as
14//!   `(tl*(1-cf) + tr*cf)*(1-rf) + (bl*(1-cf) + br*cf)*rf`, which is
15//!   algebraically the same as summing four weighted corners and is not the
16//!   same `f64`. Nesting keeps each partial sum between two corners that are
17//!   neighbours, and so of similar magnitude.
18//! - Each of those three `a*b + c*d` pairs is a `mul_add` — one rounding
19//!   instead of two, and a single `fmadd` instruction on every target this
20//!   builds for. Written as a separate multiply and add it is a *different*
21//!   answer, by about an ulp; `mul_add` is the more accurate one.
22
23use crate::error::{Error, Result};
24
25/// A COO sparse matrix, as `HiCReader::read_sparse_values` returns.
26///
27/// Repeated coordinates accumulate, per the COO convention: an element's value
28/// is the sum of every entry naming it.
29#[derive(Debug, Clone, Default)]
30pub struct CooMatrix {
31    pub values: Vec<f32>,
32    pub row: Vec<u32>,
33    pub col: Vec<u32>,
34    pub shape: (usize, usize),
35}
36
37/// Resize a flat row-major matrix by bilinear interpolation.
38///
39/// The scale maps an output coordinate onto a source one so that the two grids
40/// share their *corners* — output 0 samples source 0, and the last output
41/// samples the last source — which is what keeps a resize of a matrix to its
42/// own shape an identity.
43pub fn bilinear(
44    data: &[f32],
45    shape: (usize, usize),
46    new_shape: (usize, usize),
47) -> Result<Vec<f32>> {
48    let (old_rows, old_cols) = shape;
49    let (new_rows, new_cols) = new_shape;
50    if data.len() != old_rows * old_cols {
51        return Err(Error::invalid(
52            "bilinear resize: data size does not match shape",
53        ));
54    }
55    if data.is_empty() && new_rows > 0 && new_cols > 0 {
56        return Err(Error::invalid(
57            "bilinear resize: cannot resize an empty array to a non-empty shape",
58        ));
59    }
60    if new_rows == 0 || new_cols == 0 {
61        return Ok(Vec::new());
62    }
63
64    let (row_scale, col_scale) = scales((old_rows, old_cols), (new_rows, new_cols));
65
66    let mut out = vec![0.0f32; new_rows * new_cols];
67    for i in 0..new_rows {
68        // Everything about the source rows is fixed by `i`, so it is worked out
69        // once per output row rather than once per output cell.
70        let sr = i as f64 * row_scale;
71        let r0 = sr as usize;
72        let r1 = (r0 + 1).min(old_rows - 1);
73        let rf = sr - r0 as f64;
74        let (top, bottom) = (r0 * old_cols, r1 * old_cols);
75
76        for j in 0..new_cols {
77            let sc = j as f64 * col_scale;
78            let c0 = sc as usize;
79            let c1 = (c0 + 1).min(old_cols - 1);
80            let cf = sc - c0 as f64;
81
82            let tl = data[top + c0] as f64;
83            let tr = data[top + c1] as f64;
84            let bl = data[bottom + c0] as f64;
85            let br = data[bottom + c1] as f64;
86            out[i * new_cols + j] = interpolate(tl, tr, bl, br, rf, cf);
87        }
88    }
89    Ok(out)
90}
91
92/// The same, for a sparse matrix.
93///
94/// Only the small neighbourhood of output cells each non-zero input cell can
95/// influence is visited, so the cost follows the number of non-zeros rather
96/// than the full output area. Entries that interpolate to exactly zero are left
97/// out, and the result comes back in row-major order — which is what
98/// [`compress_sparse_by_color`] needs, since it merges a run only against the
99/// last span it opened.
100pub fn bilinear_sparse(data: &CooMatrix, new_shape: (usize, usize)) -> Result<CooMatrix> {
101    let (old_rows, old_cols) = data.shape;
102    let (new_rows, new_cols) = new_shape;
103
104    if data.row.len() != data.values.len() || data.col.len() != data.values.len() {
105        return Err(Error::invalid(
106            "bilinear resize: values, row and col must have equal length",
107        ));
108    }
109    for i in 0..data.values.len() {
110        if data.row[i] as usize >= old_rows || data.col[i] as usize >= old_cols {
111            return Err(Error::invalid(
112                "bilinear resize: coordinate outside declared shape",
113            ));
114        }
115    }
116    if (old_rows == 0 || old_cols == 0) && new_rows > 0 && new_cols > 0 {
117        return Err(Error::invalid(
118            "bilinear resize: cannot resize an empty array to a non-empty shape",
119        ));
120    }
121    // Empty output: nothing to interpolate into. Returning early also keeps the
122    // `new_rows - 1` extents below from wrapping around.
123    if new_rows == 0 || new_cols == 0 {
124        return Ok(CooMatrix {
125            shape: new_shape,
126            ..Default::default()
127        });
128    }
129
130    let (row_scale, col_scale) = scales((old_rows, old_cols), (new_rows, new_cols));
131
132    // Dense lookup for O(1) sparse reads, accumulating repeated coordinates.
133    let mut sparse_map = std::collections::HashMap::with_capacity(data.values.len());
134    for i in 0..data.values.len() {
135        let key = ((data.row[i] as u64) << 32) | data.col[i] as u64;
136        *sparse_map.entry(key).or_insert(0.0f32) += data.values[i];
137    }
138    let get = |r: usize, c: usize| -> f32 {
139        sparse_map
140            .get(&(((r as u64) << 32) | c as u64))
141            .copied()
142            .unwrap_or(0.0)
143    };
144
145    // Pass 1: which output cells any non-zero input cell can influence.
146    // Collecting the set first means a cell reached from several inputs is
147    // computed once rather than once per input, and it bounds the degenerate
148    // case: at a scale of zero every input influences the whole output.
149    let mut targets = std::collections::HashSet::new();
150    let full_output = new_rows * new_cols;
151    for idx in 0..data.values.len() {
152        // Skipping zeroes cannot skip a coordinate that matters: an accumulated
153        // value is only non-zero if some entry naming it was, and that entry
154        // visits the same neighbourhood. Interpolation reads the accumulated
155        // total through `get`, so duplicates are never counted twice.
156        if data.values[idx] == 0.0 {
157            continue;
158        }
159        // Output cells this input influences, by inverse mapping out = in /
160        // scale. Output `o` samples source rows floor(o * scale) and the next,
161        // so `src_r` is read when o * scale lies in [src_r - 1, src_r + 1) — a
162        // half-width of 1/scale output cells, not 1. The two are equal only at
163        // scale == 1; upsampling with a fixed +/-1 window silently drops
164        // contributions.
165        //
166        // A scale of zero means the output or the input has a single row, and
167        // every output row then draws on source row 0.
168        let (r_min, r_max) = influence(data.row[idx], row_scale, new_rows);
169        let (c_min, c_max) = influence(data.col[idx], col_scale, new_cols);
170        for r in r_min..=r_max {
171            for c in c_min..=c_max {
172                targets.insert(((r as u64) << 32) | c as u64);
173            }
174        }
175        // Every output cell is already spoken for, so no further input can add
176        // one. Without this the zero-scale case keeps re-inserting the whole
177        // output grid, once per non-zero.
178        if targets.len() >= full_output {
179            break;
180        }
181    }
182
183    // Pass 2: interpolate each influenced output cell once. Sorted, which for a
184    // key of (row << 32 | col) is row-major order.
185    let mut ordered: Vec<u64> = targets.into_iter().collect();
186    ordered.sort_unstable();
187
188    let mut out = CooMatrix {
189        shape: new_shape,
190        values: Vec::with_capacity(ordered.len()),
191        row: Vec::with_capacity(ordered.len()),
192        col: Vec::with_capacity(ordered.len()),
193    };
194    for key in ordered {
195        let (or_, oc) = ((key >> 32) as u32, key as u32);
196        let sr = or_ as f64 * row_scale;
197        let sc = oc as f64 * col_scale;
198        let r0 = sr as usize;
199        let c0 = sc as usize;
200        let r1 = (r0 + 1).min(old_rows - 1);
201        let c1 = (c0 + 1).min(old_cols - 1);
202        let (rf, cf) = (sr - r0 as f64, sc - c0 as f64);
203
204        let interp = interpolate(
205            get(r0, c0) as f64,
206            get(r0, c1) as f64,
207            get(r1, c0) as f64,
208            get(r1, c1) as f64,
209            rf,
210            cf,
211        );
212        if interp != 0.0 {
213            out.row.push(or_);
214            out.col.push(oc);
215            out.values.push(interp);
216        }
217    }
218    Ok(out)
219}
220
221/// One output cell from its four source corners.
222///
223/// Three `mul_add`s: fused, so each pair rounds once rather than twice. See
224/// the module docs on why the expression is nested rather than flattened.
225fn interpolate(tl: f64, tr: f64, bl: f64, br: f64, rf: f64, cf: f64) -> f32 {
226    let top = tl.mul_add(1.0 - cf, tr * cf);
227    let bottom = bl.mul_add(1.0 - cf, br * cf);
228    top.mul_add(1.0 - rf, bottom * rf) as f32
229}
230
231/// A single output row or column samples only index 0, so a scale of 0 is right
232/// rather than a division by zero.
233fn scales(old: (usize, usize), new: (usize, usize)) -> (f64, f64) {
234    let row = if new.0 > 1 {
235        (old.0 - 1) as f64 / (new.0 - 1) as f64
236    } else {
237        0.0
238    };
239    let col = if new.1 > 1 {
240        (old.1 - 1) as f64 / (new.1 - 1) as f64
241    } else {
242        0.0
243    };
244    (row, col)
245}
246
247/// The inclusive range of output indices one source index can reach.
248fn influence(src: u32, scale: f64, new_len: usize) -> (u32, u32) {
249    if scale == 0.0 {
250        return (0, new_len as u32 - 1);
251    }
252    let out = src as f64 / scale;
253    let radius = 1.0 / scale;
254    let min = (out - radius).floor().max(0.0) as u32;
255    let max = (out + radius).ceil().min((new_len - 1) as f64) as u32;
256    (min, max)
257}
258
259/// Group the entries of a sparse matrix by value bin, as runs of adjacent
260/// cells.
261///
262/// `color_count` equal-width bins are laid over `[0, max value]`, each
263/// `max value / color_count` wide. The last is closed at both ends, so the
264/// maximum falls in it rather than in one of its own.
265///
266/// Returns one flat list of `{row, column, length}` triples per bin, holding
267/// the entries whose value falls in it. Consecutive cells of a row extend the
268/// last triple instead of opening one, so a run of one colour is a single span.
269///
270/// What a renderer draws, worked out once instead of per frame. The merge only
271/// looks at the last triple of a bin, so entries out of row-major order still
272/// come back, as spans of length 1.
273///
274/// Values outside the scale are clamped to its ends rather than dropped, as a
275/// colour scale does. NaN and infinite values belong to no bin and are left out
276/// of both the scale and the output. A matrix with no scale to spread comes
277/// back as empty bins.
278///
279/// `row` and `col` must be as long as `values`; the binding layer checks that
280/// and reports a mismatch as its own kind of failure. A short one is treated
281/// here as no entry at all.
282pub fn compress_sparse_by_color(
283    values: &[f32],
284    row: &[u32],
285    col: &[u32],
286    color_count: u32,
287) -> Result<Vec<Vec<u32>>> {
288    if color_count == 0 {
289        return Err(Error::invalid(
290            "compress_sparse_by_color: color_count must be positive",
291        ));
292    }
293    let mut result = vec![Vec::<u32>::new(); color_count as usize];
294    if values.is_empty() {
295        return Ok(result);
296    }
297
298    // The scale starts at 0 rather than at the smallest value: a sparse matrix
299    // is mostly the zeros it does not store, and a scale that left them off its
300    // low end would colour them as something else.
301    let min_value = 0.0f32;
302    let mut max_value = min_value;
303    for &value in values {
304        // Non-finite entries take no part in the scale: an infinity would
305        // stretch it until every other value fell in the first bin, and a NaN
306        // would leave the whole scale NaN and every output bin empty.
307        if value.is_finite() && value > max_value {
308            max_value = value;
309        }
310    }
311    // No scale to spread the values over.
312    if max_value <= min_value {
313        return Ok(result);
314    }
315    let range = max_value - min_value;
316    // Scaled by the bin count, not the last bin index, so the bins are the
317    // equal ones the contract promises. `color_count - 1` left the top bin
318    // holding the maximum alone — for a renderer, a colour almost nothing is
319    // drawn in.
320    let scale = color_count as f32;
321    let last_bin = color_count - 1;
322
323    for i in 0..values.len().min(row.len()).min(col.len()) {
324        if !values[i].is_finite() {
325            continue;
326        }
327        let bin = ((values[i] - min_value) / range * scale).floor();
328        // Clamped before the cast: the maximum itself lands on `color_count`,
329        // and clamping it here is what closes the top bin at both ends.
330        let value_bin = if bin <= 0.0 {
331            0
332        } else if bin >= last_bin as f32 {
333            last_bin
334        } else {
335            bin as u32
336        };
337
338        let spans = &mut result[value_bin as usize];
339        let n = spans.len();
340        if n >= 3 && spans[n - 3] == row[i] && spans[n - 2] + spans[n - 1] == col[i] {
341            spans[n - 1] += 1;
342            continue;
343        }
344        spans.extend_from_slice(&[row[i], col[i], 1]);
345    }
346    Ok(result)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn coo(entries: &[(u32, u32, f32)], shape: (usize, usize)) -> CooMatrix {
354        CooMatrix {
355            values: entries.iter().map(|e| e.2).collect(),
356            row: entries.iter().map(|e| e.0).collect(),
357            col: entries.iter().map(|e| e.1).collect(),
358            shape,
359        }
360    }
361
362    #[test]
363    fn resizing_to_its_own_shape_is_the_identity() {
364        let data: Vec<f32> = (0..12).map(|v| v as f32).collect();
365        assert_eq!(bilinear(&data, (3, 4), (3, 4)).unwrap(), data);
366    }
367
368    #[test]
369    fn the_corners_are_kept_whatever_the_new_shape() {
370        let data = vec![1.0f32, 2.0, 3.0, 4.0];
371        let out = bilinear(&data, (2, 2), (5, 5)).unwrap();
372        assert_eq!(out[0], 1.0);
373        assert_eq!(out[4], 2.0);
374        assert_eq!(out[20], 3.0);
375        assert_eq!(out[24], 4.0);
376    }
377
378    #[test]
379    fn a_midpoint_is_the_mean_of_its_four_neighbours() {
380        let data = vec![0.0f32, 10.0, 20.0, 30.0];
381        let out = bilinear(&data, (2, 2), (3, 3)).unwrap();
382        // The centre of a 3x3 samples exactly halfway in both directions.
383        assert_eq!(out[4], 15.0);
384        // And the edge midpoints are the means of their two neighbours.
385        assert_eq!(out[1], 5.0);
386        assert_eq!(out[3], 10.0);
387    }
388
389    #[test]
390    fn shrinking_samples_rather_than_averages() {
391        let data: Vec<f32> = (0..16).map(|v| v as f32).collect();
392        let out = bilinear(&data, (4, 4), (2, 2)).unwrap();
393        // Corners of the source, since the grids share them.
394        assert_eq!(out, [0.0, 3.0, 12.0, 15.0]);
395    }
396
397    #[test]
398    fn a_single_output_cell_samples_the_first_source_one() {
399        let data = vec![7.0f32, 8.0, 9.0, 10.0];
400        assert_eq!(bilinear(&data, (2, 2), (1, 1)).unwrap(), [7.0]);
401    }
402
403    #[test]
404    fn a_mismatched_shape_is_refused_rather_than_read_past() {
405        // These messages reach Python unchanged and callers match on
406        // fragments of them, so the prefix is contract. It names the operation
407        // — `bilinear` and `bilinear_sparse` share it — rather than either
408        // function, which is what keeps the two saying the same thing about the
409        // same mistake.
410        let err = bilinear(&[1.0, 2.0], (3, 4), (2, 2))
411            .unwrap_err()
412            .to_string();
413        assert_eq!(err, "bilinear resize: data size does not match shape");
414        let err = bilinear(&[], (0, 0), (2, 2)).unwrap_err().to_string();
415        assert_eq!(
416            err,
417            "bilinear resize: cannot resize an empty array to a non-empty shape"
418        );
419    }
420
421    #[test]
422    fn an_empty_target_is_empty_not_an_error() {
423        assert!(bilinear(&[1.0, 2.0, 3.0, 4.0], (2, 2), (0, 5))
424            .unwrap()
425            .is_empty());
426    }
427
428    // -- sparse ------------------------------------------------------------
429
430    #[test]
431    fn a_sparse_resize_agrees_with_the_dense_one_cell_for_cell() {
432        // The two are separate implementations of the same interpolation, and
433        // the sparse one visits a neighbourhood rather than the whole grid.
434        let entries = [(0u32, 0u32, 1.0f32), (1, 2, 5.0), (3, 3, -2.0), (2, 1, 4.5)];
435        let sparse = coo(&entries, (4, 4));
436        let mut dense = vec![0.0f32; 16];
437        for (r, c, v) in entries {
438            dense[r as usize * 4 + c as usize] = v;
439        }
440        for new_shape in [(2, 2), (4, 4), (7, 7), (3, 5)] {
441            let want = bilinear(&dense, (4, 4), new_shape).unwrap();
442            let got = bilinear_sparse(&sparse, new_shape).unwrap();
443            assert_eq!(got.shape, new_shape);
444            for i in 0..got.values.len() {
445                let flat = got.row[i] as usize * new_shape.1 + got.col[i] as usize;
446                assert_eq!(got.values[i], want[flat], "{new_shape:?} entry {i}");
447            }
448            // And every cell it left out is one the dense resize made zero.
449            let listed: std::collections::HashSet<usize> = (0..got.values.len())
450                .map(|i| got.row[i] as usize * new_shape.1 + got.col[i] as usize)
451                .collect();
452            for (flat, value) in want.iter().enumerate() {
453                assert!(
454                    *value == 0.0 || listed.contains(&flat),
455                    "{new_shape:?} {flat}"
456                );
457            }
458        }
459    }
460
461    #[test]
462    fn a_sparse_resize_comes_back_in_row_major_order() {
463        // The hash-set pass 1 is what made this worth asserting: unsorted
464        // output degrades compress_sparse_by_color to one span per cell.
465        let sparse = coo(
466            &[(5, 5, 1.0), (0, 9, 2.0), (9, 0, 3.0), (2, 2, 4.0)],
467            (10, 10),
468        );
469        let out = bilinear_sparse(&sparse, (6, 6)).unwrap();
470        let keys: Vec<u64> = (0..out.values.len())
471            .map(|i| ((out.row[i] as u64) << 32) | out.col[i] as u64)
472            .collect();
473        assert!(keys.windows(2).all(|w| w[0] < w[1]), "{keys:?}");
474    }
475
476    #[test]
477    fn repeated_sparse_coordinates_accumulate() {
478        let sparse = coo(&[(0, 0, 1.0), (0, 0, 2.0)], (2, 2));
479        let out = bilinear_sparse(&sparse, (2, 2)).unwrap();
480        assert_eq!(out.values[0], 3.0);
481    }
482
483    #[test]
484    fn a_sparse_coordinate_outside_the_shape_is_refused() {
485        let sparse = coo(&[(4, 0, 1.0)], (2, 2));
486        let err = bilinear_sparse(&sparse, (2, 2)).unwrap_err().to_string();
487        assert_eq!(err, "bilinear resize: coordinate outside declared shape");
488    }
489
490    // -- compress_sparse_by_color ------------------------------------------
491
492    fn cells_of(spans: &[u32]) -> Vec<u32> {
493        spans.chunks(3).flat_map(|s| s[1]..s[1] + s[2]).collect()
494    }
495
496    #[test]
497    fn the_colour_scale_is_split_into_equal_bins() {
498        // It scaled by color_count - 1, so the top bin held the exact maximum
499        // alone where the contract promises N equal bins.
500        let values: Vec<f32> = (0..=100).map(|v| v as f32).collect();
501        let row = vec![0u32; 101];
502        let col: Vec<u32> = (0..101).collect();
503        let bins = compress_sparse_by_color(&values, &row, &col, 4).unwrap();
504
505        let counts: Vec<usize> = bins.iter().map(|b| cells_of(b).len()).collect();
506        assert!(
507            counts.iter().max().unwrap() - counts.iter().min().unwrap() <= 1,
508            "{counts:?}"
509        );
510        assert!(
511            counts[3] > 1 && cells_of(&bins[3]).contains(&100),
512            "{counts:?}"
513        );
514        let mut all: Vec<u32> = bins.iter().flat_map(|b| cells_of(b)).collect();
515        all.sort_unstable();
516        assert_eq!(all, (0..101).collect::<Vec<u32>>());
517    }
518
519    #[test]
520    fn a_run_of_one_colour_is_a_single_span() {
521        let values = vec![5.0f32; 200];
522        let row = vec![0u32; 200];
523        let col: Vec<u32> = (0..200).collect();
524        let bins = compress_sparse_by_color(&values, &row, &col, 4).unwrap();
525        let spans: usize = bins.iter().map(|b| b.len() / 3).sum();
526        assert_eq!(spans, 1);
527    }
528
529    #[test]
530    fn a_new_row_opens_a_span_rather_than_extending_the_last() {
531        let values = vec![5.0f32; 4];
532        let bins = compress_sparse_by_color(&values, &[0, 0, 1, 1], &[0, 1, 0, 1], 1).unwrap();
533        assert_eq!(bins[0], [0, 0, 2, 1, 0, 2]);
534    }
535
536    #[test]
537    fn non_finite_values_take_no_part_in_the_scale_or_the_output() {
538        let values = [0.5f32, f32::INFINITY, 2.0, f32::NAN];
539        let bins = compress_sparse_by_color(&values, &[0; 4], &[0, 1, 2, 3], 2).unwrap();
540        let listed: Vec<u32> = bins.iter().flat_map(|b| cells_of(b)).collect();
541        assert_eq!(listed.len(), 2, "{bins:?}");
542        // The infinity is what makes this worth asserting: had it counted, the
543        // scale would run to it and both values would fall in the first bin.
544        // Ignored, the scale is [0, 2] and they land one per bin -- 2.0 by the
545        // clamp that closes the top one, since floor(2/2*2) is the bin count.
546        assert_eq!(cells_of(&bins[0]), [0]);
547        assert_eq!(cells_of(&bins[1]), [2]);
548    }
549
550    #[test]
551    fn a_matrix_with_no_scale_comes_back_as_empty_bins() {
552        let bins = compress_sparse_by_color(&[0.0, -3.0], &[0, 0], &[0, 1], 3).unwrap();
553        assert_eq!(bins.len(), 3);
554        assert!(bins.iter().all(|b| b.is_empty()));
555        assert_eq!(compress_sparse_by_color(&[], &[], &[], 2).unwrap().len(), 2);
556    }
557
558    #[test]
559    fn a_zero_color_count_is_refused() {
560        let err = compress_sparse_by_color(&[1.0], &[0], &[0], 0)
561            .unwrap_err()
562            .to_string();
563        assert!(err.contains("must be positive"), "{err}");
564        // The ragged case belongs to the caller; see the doc comment.
565    }
566}