Skip to main content

bixverse_sparse/
sparse.rs

1//! Sparse matrix formats, sparse operations and helpers to transform different
2//! formats into each other. Free of any dense linear algebra dependency.
3
4use num_traits::Float;
5use rayon::prelude::*;
6use serde::{Deserialize, Serialize};
7use std::ops::{Add, AddAssign, Mul};
8
9use crate::errors::SparseErrors;
10use crate::traits::BixverseNumeric;
11
12/// Split `0..n` into one contiguous range per rayon worker.
13///
14/// For fan-outs where each worker carries scratch proportional to the whole
15/// problem, one chunk per worker is what keeps the number of allocations and
16/// merges down to the thread count. Rayon's own splitting is the better choice
17/// whenever the per-item work is self-contained.
18///
19/// ### Params
20///
21/// * `n` - Length of the range to split
22///
23/// ### Returns
24///
25/// Half-open `(start, end)` ranges covering `0..n`. Empty when `n` is zero.
26pub fn thread_chunks(n: usize) -> Vec<(usize, usize)> {
27    let n_threads = rayon::current_num_threads().max(1);
28    let chunk = n.div_ceil(n_threads).max(1);
29
30    (0..n)
31        .step_by(chunk)
32        .map(|start| (start, (start + chunk).min(n)))
33        .collect()
34}
35
36// Compressed sparse structures //
37//////////////////////////////////
38
39/////////////////////////
40// Structures: structs //
41/////////////////////////
42
43/// Type to describe the CompressedSparseFormat
44#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
45pub enum CompressedSparseFormat {
46    /// CSC-formatted data
47    Csc,
48    /// CSR-formatted data
49    Csr,
50}
51
52/// Display implementation for [CompressedSparseFormat]
53impl std::fmt::Display for CompressedSparseFormat {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            CompressedSparseFormat::Csc => write!(f, "CSC"),
57            CompressedSparseFormat::Csr => write!(f, "CSR"),
58        }
59    }
60}
61
62impl CompressedSparseFormat {
63    /// Returns boolean if it's CSC
64    ///
65    /// ### Returns
66    ///
67    /// Boolean indicating if CSC
68    #[inline(always)]
69    pub fn is_csc(&self) -> bool {
70        matches!(self, CompressedSparseFormat::Csc)
71    }
72    /// Returns boolean if it's CSR
73    ///
74    /// ### Returns
75    ///
76    /// Boolean indicating if CSR
77    #[inline(always)]
78    pub fn is_csr(&self) -> bool {
79        matches!(self, CompressedSparseFormat::Csr)
80    }
81}
82
83/// Generate structure to store sparse rows or columns
84pub struct SparseAxis<T, U = T> {
85    /// The indices of the values/non-zero positions
86    pub indices: Vec<usize>,
87    /// The values in the row/columns
88    pub data: Vec<T>,
89    /// An optional second data layer
90    pub data_2: Option<Vec<U>>,
91    /// Is the data stored in `Csr` or `Csc`. `Csr` -> sparse row; `Csc` ->
92    /// sparse column
93    pub cs_type: CompressedSparseFormat,
94    /// Total values in that dimension
95    pub len: usize,
96}
97
98impl<T, U> SparseAxis<T, U>
99where
100    T: BixverseNumeric,
101    U: BixverseNumeric,
102{
103    /// Generate a new `SparseAxis` in CSC format
104    ///
105    /// ### Params
106    ///
107    /// * `indices` - The indices of the values/non-zero positions
108    /// * `data` - The values in the row/columns
109    /// * `data_2` - An optional second data layer
110    /// * `len` - Number of rows in this sparse column
111    pub fn new_csc(indices: Vec<usize>, data: Vec<T>, data_2: Option<Vec<U>>, len: usize) -> Self {
112        SparseAxis {
113            indices,
114            data,
115            data_2,
116            cs_type: CompressedSparseFormat::Csc,
117            len,
118        }
119    }
120
121    /// Get references to the indices and second layer
122    ///
123    /// ### Returns
124    ///
125    /// A tuple of `(indices, data_2)`
126    pub fn get_indices_data_2(&self) -> Result<(&[usize], &[U]), SparseErrors> {
127        let indices = &self.indices;
128        let data_2 = self
129            .data_2
130            .as_ref()
131            .ok_or(SparseErrors::Data2NotAvailable)?;
132
133        Ok((indices, data_2))
134    }
135
136    /// Construct a `SparseAxis` in CSC format from index and value vectors.
137    ///
138    /// Stores `indices` (converted to `usize`) in `self.indices`, leaves
139    /// `data` empty, and places `values` into `data_2`. Useful for building
140    /// single-target test fixtures and lightweight sparse columns where only
141    /// the float layer is needed.
142    ///
143    /// ### Params
144    ///
145    /// * `indices` - Non-zero positions (must be convertible to `usize`).
146    /// * `values` - Corresponding values, stored in `data_2`.
147    ///
148    /// ### Panics
149    ///
150    /// Panics if `indices` and `values` differ in length.
151    pub fn from_vecs_to_csc(indices: Vec<T>, values: Vec<U>) -> Result<Self, SparseErrors>
152    where
153        T: Into<usize>,
154    {
155        if indices.len() != values.len() {
156            return Err(SparseErrors::DimensionMisMatchSparse {
157                indices_len: indices.len(),
158                data_len: values.len(),
159            });
160        }
161
162        let usize_indices: Vec<usize> = indices.into_iter().map(Into::into).collect();
163        let len = usize_indices.last().map_or(0, |&i| i + 1);
164
165        Ok(Self {
166            indices: usize_indices,
167            data: Vec::new(),
168            data_2: Some(values),
169            cs_type: CompressedSparseFormat::Csc,
170            len,
171        })
172    }
173}
174
175/// Structure to store compressed sparse data with two potential data layers.
176/// Think for example in single cell raw counts and normalised counts.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct CompressedSparseData2<T, U = T>
179where
180    T: Clone,
181    U: Clone,
182{
183    /// The first data slot for this compressed sparse data format
184    pub data: Vec<T>,
185    /// The indices of the data points
186    pub indices: Vec<u32>,
187    /// The indptr of the data points
188    pub indptr: Vec<u32>,
189    /// Enum defining if the data is stored in CSC or CSR, see
190    /// [CompressedSparseFormat]
191    pub cs_type: CompressedSparseFormat,
192    /// Optional second data slot for a different layer of the data (for
193    /// example raw and normalised counts)
194    pub data_2: Option<Vec<U>>,
195    /// Shape of the data (rows, cols)
196    pub shape: (usize, usize),
197}
198
199impl<T, U> CompressedSparseData2<T, U>
200where
201    T: BixverseNumeric,
202    U: BixverseNumeric,
203{
204    /// Generate a nes CSC version of the matrix
205    ///
206    /// ### Params
207    ///
208    /// * `data` - The underlying data
209    /// * `indices` - The index positions (in this case row indices)
210    /// * `indptr` - The index pointer (in this case the column index pointers)
211    /// * `data2` - An optional second layer
212    /// * `shape` - `(nrow, ncol)`.
213    ///
214    /// ### Returns
215    ///
216    /// Self as a CSC-type matrix
217    pub fn new_csc(
218        data: &[T],
219        indices: &[u32],
220        indptr: &[u32],
221        data2: Option<&[U]>,
222        shape: (usize, usize),
223    ) -> Self {
224        Self::from_parts(
225            data.to_vec(),
226            indices.to_vec(),
227            indptr.to_vec(),
228            data2.map(|d| d.to_vec()),
229            CompressedSparseFormat::Csc,
230            shape,
231        )
232    }
233
234    /// Generate a nes CSR version of the matrix
235    ///
236    /// ### Params
237    ///
238    /// * `data` - The underlying data
239    /// * `indices` - The index positions (in this case row indices)
240    /// * `indptr` - The index pointer (in this case the column index pointers)
241    /// * `data2` - An optional second layer
242    /// * `shape` - `(nrow, ncol)`.
243    ///
244    /// ### Returns
245    ///
246    /// Self as a CSR-type matrix
247    pub fn new_csr(
248        data: &[T],
249        indices: &[u32],
250        indptr: &[u32],
251        data2: Option<&[U]>,
252        shape: (usize, usize),
253    ) -> Self {
254        Self::from_parts(
255            data.to_vec(),
256            indices.to_vec(),
257            indptr.to_vec(),
258            data2.map(|d| d.to_vec()),
259            CompressedSparseFormat::Csr,
260            shape,
261        )
262    }
263
264    /// Take ownership of buffers that were built for this matrix
265    ///
266    /// [CompressedSparseData2::new_csr] and its CSC sibling copy all three
267    /// buffers, which doubles peak memory for any caller that assembled them
268    /// itself and then throws the originals away.
269    ///
270    /// ### Params
271    ///
272    /// * `data` - The underlying data, moved.
273    /// * `indices` - The minor-axis indices, moved.
274    /// * `indptr` - The major-axis pointers, moved.
275    /// * `data2` - An optional second layer, moved.
276    /// * `cs_type` - Which orientation the buffers describe.
277    /// * `shape` - `(nrow, ncol)`.
278    ///
279    /// ### Returns
280    ///
281    /// The matrix, with no buffer copied.
282    pub fn from_parts(
283        data: Vec<T>,
284        indices: Vec<u32>,
285        indptr: Vec<u32>,
286        data2: Option<Vec<U>>,
287        cs_type: CompressedSparseFormat,
288        shape: (usize, usize),
289    ) -> Self {
290        Self {
291            data,
292            indices,
293            indptr,
294            cs_type,
295            data_2: data2,
296            shape,
297        }
298    }
299
300    /// Transform from CSC to CSR or vice versa
301    ///
302    /// ### Returns
303    ///
304    /// The transformed/transposed version
305    pub fn transform(&self) -> Self {
306        transpose_sparse(self)
307    }
308
309    /// Transform from CSC to CSR or vice versa, keeping only one data layer.
310    ///
311    /// ### Params
312    ///
313    /// * `use_second_layer` - If `true`, the output keeps `data_2` and `data`
314    ///   is empty. If `false`, the output keeps `data` and `data_2` is `None`.
315    ///
316    /// ### Returns
317    ///
318    /// The transformed/transposed version with only the requested layer.
319    pub fn transform_single_layer(&self, use_second_layer: bool) -> Result<Self, SparseErrors> {
320        transpose_sparse_single_layer(self, use_second_layer)
321    }
322
323    /// Transpose and convert
324    ///
325    /// ### Returns
326    ///
327    /// The transformed/transposed version
328    pub fn transpose_and_convert(&self) -> Self {
329        // A CSC matrix reads as the CSR of its transpose with no data movement.
330        // A CSR matrix is converted first, then relabelled the same way.
331        let mut out = match self.cs_type {
332            CompressedSparseFormat::Csr => transpose_sparse(self),
333            CompressedSparseFormat::Csc => self.clone(),
334        };
335        out.cs_type = CompressedSparseFormat::Csr;
336        out.shape = (self.shape.1, self.shape.0);
337        out
338    }
339
340    /// Create a sparse matrix from an upper triangular matrix.
341    ///
342    /// ### Params
343    ///
344    /// * `upper_triangle` - The upper triangular matrix.
345    /// * `n` - The number of rows and columns in the matrix.
346    /// * `include_diagonal` - Whether to include the diagonal elements.
347    /// * `format` - The format of the sparse matrix, see
348    ///   [CompressedSparseFormat].
349    ///
350    /// ### Returns
351    ///
352    /// A sparse matrix.
353    pub fn from_upper_triangle_sym(
354        upper_triangle: &[T],
355        n: usize,
356        include_diagonal: bool,
357        format: CompressedSparseFormat,
358    ) -> Self
359    where
360        T: BixverseNumeric + Float,
361    {
362        // lambda function in Rust style...
363        let get_value = |row: usize, col: usize| -> T {
364            if row == col {
365                if include_diagonal {
366                    let offset = row * n - row * (row + 1) / 2 + col;
367                    upper_triangle[offset]
368                } else {
369                    T::one()
370                }
371            } else if row < col {
372                let offset = if include_diagonal {
373                    row * n - row * (row + 1) / 2 + col
374                } else {
375                    row * (n - 1) - row * (row + 1) / 2 + col - 1
376                };
377                upper_triangle[offset]
378            } else {
379                let offset = if include_diagonal {
380                    col * n - col * (col + 1) / 2 + row
381                } else {
382                    col * (n - 1) - col * (col + 1) / 2 + row - 1
383                };
384                upper_triangle[offset]
385            }
386        };
387
388        Self::from_entry_fn(n, n, format, get_value)
389    }
390
391    /// Returns the shape of the matrix
392    ///
393    /// ### Returns
394    ///
395    /// A tuple of `(n_row, n_col)`
396    pub fn shape(&self) -> (usize, usize) {
397        self.shape
398    }
399
400    /// Return the number of rows
401    ///
402    /// ### Returns
403    ///
404    /// Number of rows
405    pub fn nrows(&self) -> usize {
406        self.shape.0
407    }
408
409    /// Return the number of columns
410    ///
411    /// ### Returns
412    ///
413    /// Number of columns
414    pub fn ncols(&self) -> usize {
415        self.shape.1
416    }
417
418    /// Returns the NNZ
419    ///
420    /// ### Returns
421    ///
422    /// The number of NNZ
423    pub fn get_nnz(&self) -> usize {
424        self.indices.len()
425    }
426
427    /// Return the second layer
428    ///
429    /// If this does not exist, the function will panic
430    ///
431    /// ### Returns
432    ///
433    /// Vector of the second layer
434    pub fn get_data2_unsafe(&self) -> Vec<U> {
435        self.data_2.clone().unwrap()
436    }
437
438    /// Split into one owned [`SparseAxis`] per major-axis run.
439    ///
440    /// Columns of a CSC or rows of a CSR. A layer that is empty (not
441    /// populated) stays empty on every axis.
442    ///
443    /// ### Returns
444    ///
445    /// The axes in order, each sized to the minor dimension.
446    pub fn to_sparse_axes(&self) -> Vec<SparseAxis<T, U>> {
447        let len = self.minor_dim();
448        let has_data = self.data.len() == self.indices.len();
449
450        self.indptr
451            .par_windows(2)
452            .map(|w| {
453                let (lo, hi) = (w[0] as usize, w[1] as usize);
454                SparseAxis {
455                    indices: self.indices[lo..hi].iter().map(|&i| i as usize).collect(),
456                    data: if has_data {
457                        self.data[lo..hi].to_vec()
458                    } else {
459                        Vec::new()
460                    },
461                    data_2: self.data_2.as_ref().map(|d| d[lo..hi].to_vec()),
462                    cs_type: self.cs_type,
463                    len,
464                }
465            })
466            .collect()
467    }
468
469    /////////////
470    // Slicing //
471    /////////////
472
473    /// Gather along the major axis (rows for CSR, cols for CSC).
474    ///
475    /// Arbitrary order allowed; duplicates and out-of-range indices rejected.
476    ///
477    /// ### Params
478    ///
479    /// * `keep` - The indices on the major dimensions to keep
480    ///
481    /// ### Returns
482    ///
483    /// Self sliced along the major axis
484    fn slice_major(&self, keep: &[usize]) -> Result<Self, SparseErrors> {
485        let major = self.major_dim();
486        let mut seen = vec![false; major];
487
488        let mut indptr = Vec::with_capacity(keep.len() + 1);
489        indptr.push(0u32);
490        let mut indices = Vec::new();
491        let mut data = Vec::new();
492        let mut data_2 = self.data_2.as_ref().map(|_| Vec::new());
493
494        for &m in keep {
495            if m >= major {
496                return Err(SparseErrors::SliceIndexOutOfBounds {
497                    index: m,
498                    len: major,
499                });
500            }
501            if seen[m] {
502                return Err(SparseErrors::DuplicateSliceIndex(m));
503            }
504            seen[m] = true;
505
506            let s = self.indptr[m] as usize;
507            let e = self.indptr[m + 1] as usize;
508            indices.extend_from_slice(&self.indices[s..e]);
509            data.extend_from_slice(&self.data[s..e]);
510            if let (Some(dst), Some(src)) = (data_2.as_mut(), self.data_2.as_ref()) {
511                dst.extend_from_slice(&src[s..e]);
512            }
513            indptr.push(indices.len() as u32);
514        }
515
516        let shape = match self.cs_type {
517            CompressedSparseFormat::Csr => (keep.len(), self.shape.1),
518            CompressedSparseFormat::Csc => (self.shape.0, keep.len()),
519        };
520
521        let out = Self {
522            data,
523            indices,
524            indptr,
525            cs_type: self.cs_type,
526            data_2,
527            shape,
528        };
529        out.assert_invariants();
530        Ok(out)
531    }
532
533    /// Filter + remap the minor axis (cols for CSR, rows for CSC).
534    ///
535    /// Arbitrary order allowed; duplicates and out-of-range indices rejected.
536    /// Minor indices within each major run are kept ascending in the new space.
537    ///
538    /// ### Params
539    ///
540    /// * `keep` - The indices on the major dimensions to keep
541    ///
542    /// ### Returns
543    ///
544    /// Self sliced along the minor axis (with remapping)
545    fn slice_minor(&self, keep: &[usize]) -> Result<Self, SparseErrors> {
546        let minor = self.minor_dim();
547        let mut map: Vec<Option<usize>> = vec![None; minor];
548        for (new_idx, &old) in keep.iter().enumerate() {
549            if old >= minor {
550                return Err(SparseErrors::SliceIndexOutOfBounds {
551                    index: old,
552                    len: minor,
553                });
554            }
555            if map[old].is_some() {
556                return Err(SparseErrors::DuplicateSliceIndex(old));
557            }
558            map[old] = Some(new_idx);
559        }
560
561        let major = self.major_dim();
562        let mut indptr = Vec::with_capacity(major + 1);
563        indptr.push(0u32);
564        let mut indices = Vec::new();
565        let mut data = Vec::new();
566        let mut data_2 = self.data_2.as_ref().map(|_| Vec::new());
567
568        let mut kept: Vec<(usize, usize)> = Vec::new(); // (new_minor, src_pos)
569        for m in 0..major {
570            let s = self.indptr[m];
571            let e = self.indptr[m + 1];
572            kept.clear();
573            for p in s..e {
574                if let Some(nm) = map[self.indices[p as usize] as usize] {
575                    kept.push((nm, p as usize));
576                }
577            }
578            kept.sort_unstable_by_key(|&(nm, _)| nm);
579            for &(nm, p) in &kept {
580                indices.push(nm as u32);
581                data.push(self.data[p]);
582                if let (Some(dst), Some(src)) = (data_2.as_mut(), self.data_2.as_ref()) {
583                    dst.push(src[p]);
584                }
585            }
586            indptr.push(indices.len() as u32);
587        }
588
589        let shape = match self.cs_type {
590            CompressedSparseFormat::Csr => (self.shape.0, keep.len()),
591            CompressedSparseFormat::Csc => (keep.len(), self.shape.1),
592        };
593
594        let out = Self {
595            data,
596            indices,
597            indptr,
598            cs_type: self.cs_type,
599            data_2,
600            shape,
601        };
602        out.assert_invariants();
603        Ok(out)
604    }
605
606    /// Slice the rows of the matrix
607    ///
608    /// ### Params
609    ///
610    /// * `rows` - The row indices to keep
611    ///
612    /// ### Returns
613    ///
614    /// Self with the rows to keep
615    pub fn slice_rows(&self, rows: &[usize]) -> Result<Self, SparseErrors> {
616        match self.cs_type {
617            CompressedSparseFormat::Csr => self.slice_major(rows),
618            CompressedSparseFormat::Csc => self.slice_minor(rows),
619        }
620    }
621
622    /// Slice the columns of the matrix
623    ///
624    /// ### Params
625    ///
626    /// * `cols` - The column indices to keep
627    ///
628    /// ### Returns
629    ///
630    /// Self with the cols to keep
631    pub fn slice_cols(&self, cols: &[usize]) -> Result<Self, SparseErrors> {
632        match self.cs_type {
633            CompressedSparseFormat::Csr => self.slice_minor(cols),
634            CompressedSparseFormat::Csc => self.slice_major(cols),
635        }
636    }
637
638    /// Slice across rows and columns
639    ///
640    /// ### Params
641    ///
642    /// * `rows` - The row indices to keep
643    /// * `cols` - The column indices to keep
644    ///
645    /// ### Returns
646    ///
647    /// Self with the rows and columns to keep
648    pub fn slice(&self, rows: &[usize], cols: &[usize]) -> Result<Self, SparseErrors> {
649        self.slice_rows(rows)?.slice_cols(cols)
650    }
651
652    /////////////
653    // Helpers //
654    /////////////
655
656    /// Build a matrix by scanning every `(row, col)` entry of a virtual dense
657    /// matrix and storing the non-zeros.
658    ///
659    /// ### Params
660    ///
661    /// * `nrows` - Number of rows
662    /// * `ncols` - Number of columns
663    /// * `format` - Format to generate; the scan runs along its major axis
664    /// * `get` - Value at `(row, col)`
665    ///
666    /// ### Returns
667    ///
668    /// The sparse matrix, with no second layer.
669    pub fn from_entry_fn<G>(
670        nrows: usize,
671        ncols: usize,
672        format: CompressedSparseFormat,
673        get: G,
674    ) -> Self
675    where
676        T: BixverseNumeric + Float,
677        G: Fn(usize, usize) -> T,
678    {
679        let (major, minor) = match format {
680            CompressedSparseFormat::Csr => (nrows, ncols),
681            CompressedSparseFormat::Csc => (ncols, nrows),
682        };
683
684        let mut data = Vec::new();
685        let mut indices: Vec<u32> = Vec::new();
686        let mut indptr: Vec<u32> = Vec::with_capacity(major + 1);
687        indptr.push(0);
688
689        for outer in 0..major {
690            for inner in 0..minor {
691                let val = match format {
692                    CompressedSparseFormat::Csr => get(outer, inner),
693                    CompressedSparseFormat::Csc => get(inner, outer),
694                };
695                if val != T::zero() {
696                    data.push(val);
697                    indices.push(inner as u32);
698                }
699            }
700            indptr.push(data.len() as u32);
701        }
702
703        Self::from_parts(data, indices, indptr, None, format, (nrows, ncols))
704    }
705
706    /// Return the major dimensions
707    ///
708    /// ### Returns
709    ///
710    /// Major dimension
711    fn major_dim(&self) -> usize {
712        self.indptr.len() - 1
713    }
714
715    /// Major dimension as declared by `shape`, which `indptr` must agree with.
716    ///
717    /// ### Returns
718    ///
719    /// Rows for CSR, columns for CSC.
720    fn expected_major(&self) -> usize {
721        match self.cs_type {
722            CompressedSparseFormat::Csr => self.shape.0,
723            CompressedSparseFormat::Csc => self.shape.1,
724        }
725    }
726
727    /// Returns the minor dimension
728    ///
729    /// ### Returns
730    ///
731    /// Minor dimension
732    fn minor_dim(&self) -> usize {
733        match self.cs_type {
734            CompressedSparseFormat::Csr => self.shape.1,
735            CompressedSparseFormat::Csc => self.shape.0,
736        }
737    }
738
739    /// Check every invariant that indexing this matrix relies on.
740    ///
741    /// [`Self::assert_invariants`] is a `debug_assert` helper and compiles out
742    /// in release, and it never checks the indices against the minor axis.
743    /// This is the release-mode counterpart, for input that crossed an FFI
744    /// boundary: the fields are public, so a caller can hand over an `indptr`
745    /// that disagrees with `shape` or an index past the minor axis, and any
746    /// consumer that scatters into a dense buffer sized from `shape` would
747    /// then panic inside a worker thread or, worse, silently process fewer
748    /// major runs than the shape declares.
749    ///
750    /// A layer that is empty is treated as absent rather than as a length
751    /// mismatch, since [`Self::transform_single_layer`] deliberately leaves the
752    /// unused layer empty.
753    ///
754    /// ### Returns
755    ///
756    /// `Ok(())`, or the first invariant that failed:
757    /// [`SparseErrors::SparseIndptrInvalid`] for an `indptr` that disagrees
758    /// with `shape` or with the number of stored values,
759    /// [`SparseErrors::DimensionMisMatchSparse`] for a populated layer whose
760    /// length does not match `indices`, and
761    /// [`SparseErrors::SliceIndexOutOfBounds`] for an index past the minor
762    /// axis.
763    pub fn validate(&self) -> Result<(), SparseErrors> {
764        let expected_major = self.expected_major();
765
766        if self.indptr.len() != expected_major + 1 {
767            return Err(SparseErrors::SparseIndptrInvalid {
768                detail: "length against the declared major axis",
769                expected: expected_major + 1,
770                got: self.indptr.len(),
771            });
772        }
773
774        // `indptr.len()` is now at least 1, so `last` cannot be `None`
775        let nnz = *self.indptr.last().expect("indptr length checked above") as usize;
776        if nnz != self.indices.len() {
777            return Err(SparseErrors::SparseIndptrInvalid {
778                detail: "final offset against the stored indices",
779                expected: self.indices.len(),
780                got: nnz,
781            });
782        }
783
784        if !self.indptr.windows(2).all(|w| w[0] <= w[1]) {
785            return Err(SparseErrors::SparseIndptrInvalid {
786                detail: "offsets must be non-decreasing",
787                expected: nnz,
788                got: nnz,
789            });
790        }
791
792        if !self.data.is_empty() && self.data.len() != self.indices.len() {
793            return Err(SparseErrors::DimensionMisMatchSparse {
794                indices_len: self.indices.len(),
795                data_len: self.data.len(),
796            });
797        }
798
799        if let Some(data_2) = &self.data_2
800            && !data_2.is_empty()
801            && data_2.len() != self.indices.len()
802        {
803            return Err(SparseErrors::DimensionMisMatchSparse {
804                indices_len: self.indices.len(),
805                data_len: data_2.len(),
806            });
807        }
808
809        let minor = self.minor_dim();
810        if let Some(&bad) = self
811            .indices
812            .par_iter()
813            .find_any(|&&idx| idx as usize >= minor)
814        {
815            return Err(SparseErrors::SliceIndexOutOfBounds {
816                index: bad as usize,
817                len: minor,
818            });
819        }
820
821        Ok(())
822    }
823
824    /// Assertion helper for invariants
825    pub fn assert_invariants(&self) {
826        debug_assert_eq!(self.indices.len(), self.data.len());
827        debug_assert_eq!(self.indptr.len(), self.expected_major() + 1);
828        debug_assert_eq!(*self.indptr.last().unwrap() as usize, self.data.len());
829        if let Some(d2) = &self.data_2 {
830            debug_assert_eq!(d2.len(), self.data.len());
831        }
832    }
833}
834
835/////////////////////////
836// Structures: helpers //
837/////////////////////////
838
839/// Fail unless the matrix is stored as CSR.
840///
841/// ### Params
842///
843/// * `mat` - The matrix to check
844///
845/// ### Returns
846///
847/// `Ok(())`, or [SparseErrors::SparseMatrixMustBeCsr].
848pub fn require_csr<T, U>(mat: &CompressedSparseData2<T, U>) -> Result<(), SparseErrors>
849where
850    T: Clone,
851    U: Clone,
852{
853    if mat.cs_type.is_csr() {
854        Ok(())
855    } else {
856        Err(SparseErrors::SparseMatrixMustBeCsr)
857    }
858}
859
860/// Fail unless the matrix is stored as CSC.
861///
862/// ### Params
863///
864/// * `mat` - The matrix to check
865///
866/// ### Returns
867///
868/// `Ok(())`, or [SparseErrors::SparseMatrixMustBeCsc].
869pub fn require_csc<T, U>(mat: &CompressedSparseData2<T, U>) -> Result<(), SparseErrors>
870where
871    T: Clone,
872    U: Clone,
873{
874    if mat.cs_type.is_csc() {
875        Ok(())
876    } else {
877        Err(SparseErrors::SparseMatrixMustBeCsc)
878    }
879}
880
881/// Fail unless both matrices are CSR and share a shape, as the element-wise
882/// operations need.
883///
884/// ### Params
885///
886/// * `a` - First matrix
887/// * `b` - Second matrix
888///
889/// ### Returns
890///
891/// `Ok(())`, [SparseErrors::SparseMatrixMustBeCsr] or
892/// [SparseErrors::ShapeMismatchSparse].
893fn require_csr_pair_same_shape<T>(
894    a: &CompressedSparseData2<T>,
895    b: &CompressedSparseData2<T>,
896) -> Result<(), SparseErrors>
897where
898    T: Clone,
899{
900    require_csr(a)?;
901    require_csr(b)?;
902    if a.shape != b.shape {
903        return Err(SparseErrors::ShapeMismatchSparse);
904    }
905    Ok(())
906}
907
908/// Turn per-run counts into offsets, in place.
909///
910/// ### Params
911///
912/// * `indptr` - Counts shifted by one, i.e. `indptr[i + 1]` holds the count of
913///   run `i` and `indptr[0]` is zero. Becomes the usual `indptr`.
914fn cumulate_indptr(indptr: &mut [u32]) {
915    for i in 1..indptr.len() {
916        indptr[i] += indptr[i - 1];
917    }
918}
919
920//////////////////////////////////
921// Structures: public functions //
922//////////////////////////////////
923
924/// Helper function to parse compressed sparse format
925///
926/// ### Params
927///
928/// * `s` - String specifying the type
929///
930/// ### Return
931///
932/// Returns an `Option<CompressedSparseFormat>`
933pub fn parse_compressed_sparse_format(s: &str) -> Option<CompressedSparseFormat> {
934    match s.to_lowercase().as_str() {
935        "csr" => Some(CompressedSparseFormat::Csr),
936        "csc" => Some(CompressedSparseFormat::Csc),
937        _ => None,
938    }
939}
940
941/// Check that a matrix is a structurally sound square CSR and return its order
942///
943/// Callers that index `partitions[j]` or `parent[j]` from a stored column index
944/// rely on every invariant here, and a violated one is otherwise silent: a
945/// non-monotonic `indptr` gives an empty Rust range, so the row is skipped and
946/// the caller returns a plausible answer computed on a subset of the graph. An
947/// out-of-range column index panics through whatever boundary the caller sits
948/// behind instead of erroring.
949///
950/// ### Params
951///
952/// * `graph` - The adjacency to validate.
953///
954/// ### Returns
955///
956/// The node count, or an error naming the violated invariant.
957pub fn validate_square_csr<T>(graph: &CompressedSparseData2<T>) -> Result<usize, SparseErrors>
958where
959    T: Clone,
960{
961    if !graph.cs_type.is_csr() {
962        return Err(SparseErrors::SparseMatrixMustBeCsr);
963    }
964    let (rows, cols) = graph.shape;
965    if rows != cols {
966        return Err(SparseErrors::ShapeMismatch {
967            expected: (rows, rows),
968            got: (rows, cols),
969        });
970    }
971
972    if graph.indptr.len() != rows + 1 {
973        return Err(SparseErrors::MalformedCsr(
974            "indptr length must be the row count plus one",
975        ));
976    }
977    if graph.indices.len() != graph.data.len() {
978        return Err(SparseErrors::MalformedCsr(
979            "indices and data must have the same length",
980        ));
981    }
982    if graph.indptr.windows(2).any(|w| w[0] > w[1]) {
983        return Err(SparseErrors::MalformedCsr("indptr must be non-decreasing"));
984    }
985    // `indptr` is non-empty by the length check above.
986    if graph.indptr[rows] as usize != graph.indices.len() {
987        return Err(SparseErrors::MalformedCsr(
988            "the last indptr entry must equal the number of stored values",
989        ));
990    }
991    if graph.indices.iter().any(|&j| j as usize >= rows) {
992        return Err(SparseErrors::MalformedCsr(
993            "a column index sits outside the matrix",
994        ));
995    }
996
997    Ok(rows)
998}
999
1000///////////////////////
1001// Format conversion //
1002///////////////////////
1003
1004////////////////////////
1005// Conversion: consts //
1006////////////////////////
1007
1008/// Below this many stored values the transpose stays single-threaded.
1009///
1010/// The parallel path pays for `n_threads * new_major` counters plus two sweeps
1011/// over them, which only earns its keep once the scatter itself is large enough
1012/// to leave cache. A megabyte of stored values is comfortably past that point
1013/// on every machine tested and well short of it for the small graphs and toy
1014/// matrices most callers hand over.
1015const PARALLEL_TRANSPOSE_MIN_NNZ: usize = 1 << 20;
1016
1017/// Width of the band of the new major axis the scatter fills at a time.
1018///
1019/// Unbanded, each chunk revisits every new major run once per old major index,
1020/// so a short, wide input (2000 genes by 1M cells) streams its whole
1021/// destination through the cache once per gene. Banding keeps each chunk's
1022/// writes inside `band * nnz / new_major` slots. Measured on a 1M x 2000 CSC
1023/// at 10% density (M1 Max, 10 threads): 633 ms unbanded, 194-207 ms for bands
1024/// of 512 to 4096, 280 ms at 16384, 490 ms at 65536.
1025const TRANSPOSE_MINOR_BAND: usize = 1024;
1026
1027/////////////////////////
1028// Conversion: structs //
1029/////////////////////////
1030
1031/// Destination pointer handed to every worker of the parallel scatter.
1032///
1033/// The transpose partitions the destination by `(chunk, minor)`: the counting
1034/// pass gives every pair its own half-open run of slots, and a worker only ever
1035/// advances the cursors of its own chunk. No two workers therefore address the
1036/// same slot, which is what makes the shared `*mut` sound.
1037pub struct ScatterPtr<T>(pub *mut T);
1038
1039// SAFETY: see the type doc. Writes go through disjoint per-chunk cursor runs.
1040unsafe impl<T: Send> Send for ScatterPtr<T> {}
1041// SAFETY: as above.
1042unsafe impl<T: Send> Sync for ScatterPtr<T> {}
1043
1044impl<T> ScatterPtr<T> {
1045    /// Write one value into the destination.
1046    ///
1047    /// ### Params
1048    ///
1049    /// * `pos` - Slot to write
1050    /// * `value` - Value to store
1051    ///
1052    /// # Safety
1053    ///
1054    /// `pos` must be in bounds of the buffer this was built from, and no other
1055    /// worker may write the same slot.
1056    #[inline(always)]
1057    pub unsafe fn write(&self, pos: usize, value: T) {
1058        unsafe { *self.0.add(pos) = value }
1059    }
1060}
1061
1062/////////////////////////
1063// Conversion: helpers //
1064/////////////////////////
1065
1066/// Length of the major axis and the format of the other-format twin.
1067///
1068/// The old minor axis becomes the new major axis, so a CSC matrix of `nrow`
1069/// rows converts into a CSR one with `nrow` runs and vice versa.
1070///
1071/// ### Params
1072///
1073/// * `sparse_data` - The matrix being converted
1074///
1075/// ### Returns
1076///
1077/// `(new_major, new_type)`.
1078fn converted_layout<T, U>(
1079    sparse_data: &CompressedSparseData2<T, U>,
1080) -> (usize, CompressedSparseFormat)
1081where
1082    T: BixverseNumeric,
1083    U: BixverseNumeric,
1084{
1085    let new_type = match sparse_data.cs_type {
1086        CompressedSparseFormat::Csc => CompressedSparseFormat::Csr,
1087        CompressedSparseFormat::Csr => CompressedSparseFormat::Csc,
1088    };
1089    (sparse_data.minor_dim(), new_type)
1090}
1091
1092/// Per-chunk write cursors and the transposed `indptr`.
1093///
1094/// Runs the counting phase of the transpose: one histogram per chunk of the old
1095/// major axis, folded into the new `indptr` and then rewritten in place as the
1096/// absolute slot each `(chunk, minor)` pair starts at.
1097///
1098/// ### Params
1099///
1100/// * `sparse_data` - The matrix being transposed
1101/// * `chunks` - Half-open ranges partitioning the old major axis
1102/// * `new_major` - Length of the new major axis
1103///
1104/// ### Returns
1105///
1106/// `(new_indptr, cursors, sorted)`, where `cursors` is
1107/// `chunks.len() * new_major` long and laid out chunk-major, and `sorted`
1108/// says whether every old major run has strictly ascending minor indices.
1109fn transpose_cursors<T, U>(
1110    sparse_data: &CompressedSparseData2<T, U>,
1111    chunks: &[(usize, usize)],
1112    new_major: usize,
1113) -> (Vec<u32>, Vec<u32>, bool)
1114where
1115    T: BixverseNumeric,
1116    U: BixverseNumeric,
1117{
1118    let mut cursors = vec![0u32; chunks.len() * new_major];
1119
1120    let sorted = chunks
1121        .par_iter()
1122        .zip(cursors.par_chunks_mut(new_major.max(1)))
1123        .map(|(&(start, end), counts)| {
1124            let mut sorted = true;
1125            for major in start..end {
1126                let lo = sparse_data.indptr[major] as usize;
1127                let hi = sparse_data.indptr[major + 1] as usize;
1128                let run = &sparse_data.indices[lo..hi];
1129                for &minor in run {
1130                    counts[minor as usize] += 1;
1131                }
1132                sorted &= run.windows(2).all(|w| w[0] < w[1]);
1133            }
1134            sorted
1135        })
1136        .reduce(|| true, |a, b| a && b);
1137
1138    // column sums of the histograms give the entries per new-major index
1139    let mut new_indptr = vec![0u32; new_major + 1];
1140    for counts in cursors.chunks(new_major.max(1)) {
1141        for (total, &count) in new_indptr[1..].iter_mut().zip(counts.iter()) {
1142            *total += count;
1143        }
1144    }
1145    cumulate_indptr(&mut new_indptr);
1146
1147    // rewrite the counts as absolute starts. Both the cursor row and the
1148    // running offsets are swept contiguously, so this stays cache-friendly
1149    // even when the new major axis is hundreds of thousands long.
1150    let mut running: Vec<u32> = new_indptr[..new_major].to_vec();
1151    for counts in cursors.chunks_mut(new_major.max(1)) {
1152        for (count, offset) in counts.iter_mut().zip(running.iter_mut()) {
1153            let start = *offset;
1154            *offset += *count;
1155            *count = start;
1156        }
1157    }
1158
1159    (new_indptr, cursors, sorted)
1160}
1161
1162/// Scatter the stored values into their transposed positions, in parallel.
1163///
1164/// ### Params
1165///
1166/// * `sparse_data` - The matrix being transposed
1167/// * `chunks` - Half-open ranges partitioning the old major axis
1168/// * `cursors` - Per-chunk write cursors from [`transpose_cursors`], consumed
1169/// * `new_major` - Length of the new major axis
1170/// * `out_indices` - Destination for the new minor indices
1171/// * `out_data` - Optional `(source, destination)` for the counts layer
1172/// * `out_data2` - Optional `(source, destination)` for the second layer
1173/// * `band` - Width of the band of the new major axis each chunk scatters
1174///   into at a time; `None` walks every run once, front to back. Banding
1175///   needs strictly ascending minor indices within each old major run.
1176#[allow(clippy::too_many_arguments)]
1177fn transpose_scatter<T, U>(
1178    sparse_data: &CompressedSparseData2<T, U>,
1179    chunks: &[(usize, usize)],
1180    cursors: &mut [u32],
1181    new_major: usize,
1182    band: Option<usize>,
1183    out_indices: &mut [u32],
1184    out_data: Option<(&[T], &mut [T])>,
1185    out_data2: Option<(&[U], &mut [U])>,
1186) where
1187    T: BixverseNumeric,
1188    U: BixverseNumeric,
1189{
1190    let indices_ptr = ScatterPtr(out_indices.as_mut_ptr());
1191    let (data_src, data_ptr) = match out_data {
1192        Some((src, dst)) => (Some(src), Some(ScatterPtr(dst.as_mut_ptr()))),
1193        None => (None, None),
1194    };
1195    let (data2_src, data2_ptr) = match out_data2 {
1196        Some((src, dst)) => (Some(src), Some(ScatterPtr(dst.as_mut_ptr()))),
1197        None => (None, None),
1198    };
1199
1200    chunks
1201        .par_iter()
1202        .zip(cursors.par_chunks_mut(new_major.max(1)))
1203        .for_each(|(&(start, end), cursor)| {
1204            // read position in each old major run, advanced band by band
1205            let mut read: Vec<usize> = (start..end)
1206                .map(|major| sparse_data.indptr[major] as usize)
1207                .collect();
1208            let band = band.unwrap_or(usize::MAX).max(1);
1209            let mut band_lo = 0usize;
1210            while band_lo < new_major.max(1) {
1211                let band_hi = band_lo.saturating_add(band);
1212                for (major, rd) in (start..end).zip(read.iter_mut()) {
1213                    let hi = sparse_data.indptr[major + 1] as usize;
1214                    while *rd < hi {
1215                        let idx = *rd;
1216                        let minor = sparse_data.indices[idx] as usize;
1217                        if minor >= band_hi {
1218                            break;
1219                        }
1220                        *rd += 1;
1221                        let pos = cursor[minor] as usize;
1222                        cursor[minor] += 1;
1223
1224                        // SAFETY: `pos` sits in the run this chunk owns for
1225                        // `minor`, which no other worker touches, and every
1226                        // run is inside `0..nnz` by the counting pass.
1227                        unsafe {
1228                            indices_ptr.write(pos, major as u32);
1229                            if let (Some(src), Some(dst)) = (data_src, &data_ptr) {
1230                                dst.write(pos, src[idx]);
1231                            }
1232                            if let (Some(src), Some(dst)) = (data2_src, &data2_ptr) {
1233                                dst.write(pos, src[idx]);
1234                            }
1235                        }
1236                    }
1237                }
1238                band_lo = band_hi;
1239            }
1240        });
1241}
1242
1243/// Half-open ranges the transpose should split the old major axis into.
1244///
1245/// One range keeps the whole thing single-threaded, which is what small inputs
1246/// want. Split out so the tests can force a multi-chunk split without building
1247/// a matrix past [`PARALLEL_TRANSPOSE_MIN_NNZ`].
1248///
1249/// ### Params
1250///
1251/// * `sparse_data` - The matrix being transposed
1252///
1253/// ### Returns
1254///
1255/// The chunks covering the old major axis.
1256fn major_axis_chunks<T, U>(sparse_data: &CompressedSparseData2<T, U>) -> Vec<(usize, usize)>
1257where
1258    T: BixverseNumeric,
1259    U: BixverseNumeric,
1260{
1261    let old_major_len = sparse_data.indptr.len() - 1;
1262
1263    if sparse_data.get_nnz() >= PARALLEL_TRANSPOSE_MIN_NNZ {
1264        thread_chunks(old_major_len)
1265    } else {
1266        vec![(0, old_major_len)]
1267    }
1268}
1269
1270/// Transpose a compressed sparse matrix over a given chunking of the old major
1271/// axis.
1272///
1273/// ### Params
1274///
1275/// * `sparse_data` - The input compressed sparse matrix to be transformed
1276/// * `chunks` - Half-open ranges partitioning the old major axis
1277/// * `band` - Scatter band width, see [`TRANSPOSE_MINOR_BAND`]; ignored if a
1278///   run has unsorted minor indices
1279///
1280/// ### Returns
1281///
1282/// The transposed compressed sparse matrix.
1283fn transpose_sparse_chunked<T, U>(
1284    sparse_data: &CompressedSparseData2<T, U>,
1285    chunks: &[(usize, usize)],
1286    band: usize,
1287) -> CompressedSparseData2<T, U>
1288where
1289    T: BixverseNumeric,
1290    U: BixverseNumeric,
1291{
1292    let nnz = sparse_data.get_nnz();
1293    let (nrow, ncol) = sparse_data.shape();
1294    let (new_major, new_type) = converted_layout(sparse_data);
1295
1296    let mut new_data: Vec<T> = vec![T::default(); nnz];
1297    let mut new_indices: Vec<u32> = vec![0u32; nnz];
1298    let mut new_data2: Option<Vec<U>> =
1299        sparse_data.data_2.as_ref().map(|_| vec![U::default(); nnz]);
1300
1301    let (new_indptr, mut cursors, sorted) = transpose_cursors(sparse_data, chunks, new_major);
1302
1303    let data_pair = Some((sparse_data.data.as_slice(), new_data.as_mut_slice()));
1304    let data2_pair = match (&sparse_data.data_2, &mut new_data2) {
1305        (Some(src), Some(dst)) => Some((src.as_slice(), dst.as_mut_slice())),
1306        _ => None,
1307    };
1308
1309    transpose_scatter(
1310        sparse_data,
1311        chunks,
1312        &mut cursors,
1313        new_major,
1314        sorted.then_some(band),
1315        &mut new_indices,
1316        data_pair,
1317        data2_pair,
1318    );
1319
1320    CompressedSparseData2 {
1321        data: new_data,
1322        indices: new_indices,
1323        indptr: new_indptr,
1324        cs_type: new_type,
1325        data_2: new_data2,
1326        shape: (nrow, ncol),
1327    }
1328}
1329
1330//////////////////////////////////
1331// Conversion: public functions //
1332//////////////////////////////////
1333
1334/// Re-express a compressed sparse matrix in the other format (CSC→CSR or
1335/// CSR→CSC).
1336///
1337/// Despite the name this does **not** transpose: `shape` is carried over
1338/// unchanged and only `cs_type` flips, so the result addresses the same
1339/// elements by the same `(row, col)` pair. Reach for
1340/// [`CompressedSparseData2::transpose_and_convert`] when the shape is meant to
1341/// swap.
1342///
1343/// A counting sort in O(nnz) time. Above `PARALLEL_TRANSPOSE_MIN_NNZ` the
1344/// count and the scatter both fan out over rayon: the scatter is a random-write
1345/// pass over `nnz`-sized destinations, so leaving it single-threaded makes it
1346/// the slowest step of anything that converts on the way in.
1347///
1348/// ### Params
1349///
1350/// * `sparse_data`: The input compressed sparse matrix to be converted.
1351///
1352/// ### Returns
1353///
1354/// The same matrix in the other compressed sparse format, with `shape`
1355/// unchanged and the indices of each new major run ascending.
1356pub fn transpose_sparse<T, U>(
1357    sparse_data: &CompressedSparseData2<T, U>,
1358) -> CompressedSparseData2<T, U>
1359where
1360    T: BixverseNumeric,
1361    U: BixverseNumeric,
1362{
1363    transpose_sparse_chunked(
1364        sparse_data,
1365        &major_axis_chunks(sparse_data),
1366        TRANSPOSE_MINOR_BAND,
1367    )
1368}
1369
1370/// Transpose a compressed sparse matrix (CSC→CSR or CSR→CSC), keeping only
1371/// one of the two data layers.
1372///
1373/// Useful when the caller knows the other layer is dead weight (e.g. raw
1374/// counts when only normalised counts are consumed downstream). Skips the
1375/// scatter for the unused layer entirely.
1376///
1377/// ### Params
1378///
1379/// * `sparse_data` - The input compressed sparse matrix to be transposed.
1380/// * `use_second_layer` - If `true`, only `data_2` is transposed and the
1381///   output's `data` is empty. If `false`, only `data` is transposed and the
1382///   output's `data_2` is `None`.
1383///
1384/// ### Returns
1385///
1386/// The transposed compressed sparse matrix with only the requested layer
1387/// populated.
1388pub fn transpose_sparse_single_layer<T, U>(
1389    sparse_data: &CompressedSparseData2<T, U>,
1390    use_second_layer: bool,
1391) -> Result<CompressedSparseData2<T, U>, SparseErrors>
1392where
1393    T: BixverseNumeric,
1394    U: BixverseNumeric,
1395{
1396    let nnz = sparse_data.get_nnz();
1397    let (nrow, ncol) = sparse_data.shape();
1398    let (new_major, new_type) = converted_layout(sparse_data);
1399
1400    let mut new_indices: Vec<u32> = vec![0u32; nnz];
1401
1402    // allocate only for the kept layer
1403    let (mut new_data, mut new_data2): (Vec<T>, Option<Vec<U>>) = if use_second_layer {
1404        (Vec::new(), Some(vec![U::default(); nnz]))
1405    } else {
1406        (vec![T::default(); nnz], None)
1407    };
1408
1409    let chunks = major_axis_chunks(sparse_data);
1410    let (new_indptr, mut cursors, sorted) = transpose_cursors(sparse_data, &chunks, new_major);
1411
1412    let (data_pair, data2_pair) = if use_second_layer {
1413        let src = sparse_data
1414            .data_2
1415            .as_ref()
1416            .ok_or(SparseErrors::Data2NotAvailable)?
1417            .as_slice();
1418        let dst = new_data2.as_mut().unwrap().as_mut_slice();
1419        (None, Some((src, dst)))
1420    } else {
1421        (
1422            Some((sparse_data.data.as_slice(), new_data.as_mut_slice())),
1423            None,
1424        )
1425    };
1426
1427    transpose_scatter(
1428        sparse_data,
1429        &chunks,
1430        &mut cursors,
1431        new_major,
1432        sorted.then_some(TRANSPOSE_MINOR_BAND),
1433        &mut new_indices,
1434        data_pair,
1435        data2_pair,
1436    );
1437
1438    Ok(CompressedSparseData2 {
1439        data: new_data,
1440        indices: new_indices,
1441        indptr: new_indptr,
1442        cs_type: new_type,
1443        data_2: new_data2,
1444        shape: (nrow, ncol),
1445    })
1446}
1447
1448/// Transform COO stored data into CSR
1449///
1450/// ### Params
1451///
1452/// * `rows` - Row indices
1453/// * `cols` - Col indices
1454/// * `vals` - The values to store in the matrix
1455///
1456/// ### Returns
1457///
1458/// `CompressedSparseData2` in CSR format
1459pub fn coo_to_csr<T>(
1460    rows: &[u32],
1461    cols: &[u32],
1462    vals: &[T],
1463    shape: (usize, usize),
1464) -> CompressedSparseData2<T>
1465where
1466    T: BixverseNumeric,
1467{
1468    let n_rows = shape.0;
1469
1470    // sort by (row, col) and merge duplicates
1471    let mut entries: Vec<(u32, u32, T)> = rows
1472        .iter()
1473        .zip(cols.iter())
1474        .zip(vals.iter())
1475        .map(|((&r, &c), &v)| (r, c, v))
1476        .collect();
1477
1478    entries.sort_unstable_by_key(|&(r, c, _)| (r, c));
1479
1480    // merge duplicates; can happen during additions
1481    let mut merged_entries = Vec::new();
1482    if !entries.is_empty() {
1483        let mut current = entries[0];
1484
1485        for &(r, c, v) in &entries[1..] {
1486            if r == current.0 && c == current.1 {
1487                current.2 += v;
1488            } else {
1489                if current.2 != T::default() {
1490                    merged_entries.push(current);
1491                }
1492                current = (r, c, v);
1493            }
1494        }
1495        if current.2 != T::default() {
1496            merged_entries.push(current);
1497        }
1498    }
1499
1500    // build CSR from merged entries
1501    let final_nnz = merged_entries.len();
1502    let mut data = Vec::with_capacity(final_nnz);
1503    let mut indices = Vec::with_capacity(final_nnz);
1504    let mut indptr = vec![0u32; n_rows + 1];
1505
1506    for &(row, col, val) in &merged_entries {
1507        data.push(val);
1508        indices.push(col);
1509        indptr[(row + 1) as usize] += 1;
1510    }
1511
1512    cumulate_indptr(&mut indptr);
1513
1514    CompressedSparseData2::new_csr(&data, &indices, &indptr, None, shape)
1515}
1516
1517/// Optimised COO to CSR - assumes input is already sorted by (row, col)
1518///
1519/// ### Params
1520///
1521/// * `rows` - Row indices (must be sorted by row first, then col)
1522/// * `cols` - Col indices
1523/// * `vals` - Values
1524/// * `shape` - Matrix dimensions
1525/// * `is_sorted` - If true, skips sorting step
1526///
1527/// ### Returns
1528///
1529/// CSR matrix
1530pub fn coo_to_csr_presorted<T>(
1531    rows: &[u32],
1532    cols: &[u32],
1533    vals: &[T],
1534    shape: (usize, usize),
1535) -> CompressedSparseData2<T>
1536where
1537    T: BixverseNumeric,
1538{
1539    let n_rows = shape.0;
1540    let nnz = rows.len();
1541
1542    let mut data = Vec::with_capacity(nnz);
1543    let mut indices: Vec<u32> = Vec::with_capacity(nnz);
1544    let mut indptr = vec![0u32; n_rows + 1];
1545
1546    // unsafe to squeeze out performance...
1547    unsafe {
1548        data.set_len(nnz);
1549        indices.set_len(nnz);
1550
1551        let data_ptr: *mut T = data.as_mut_ptr();
1552        let indices_ptr: *mut u32 = indices.as_mut_ptr();
1553        let indptr_ptr: *mut u32 = indptr.as_mut_ptr();
1554
1555        for i in 0..nnz {
1556            *data_ptr.add(i) = *vals.get_unchecked(i);
1557            *indices_ptr.add(i) = *cols.get_unchecked(i);
1558            let row = *rows.get_unchecked(i) as usize;
1559            *indptr_ptr.add(row + 1) += 1;
1560        }
1561    }
1562
1563    cumulate_indptr(&mut indptr);
1564
1565    CompressedSparseData2::new_csr(&data, &indices, &indptr, None, shape)
1566}
1567
1568/// Scatter a sorted undirected edge list into a symmetric CSR adjacency
1569///
1570/// Rows come out ascending without a per-row sort or a per-row allocation. The
1571/// trick is the scatter order: the `hi` endpoint of every edge is written
1572/// first, filling each row `r` with its `lo < r` partners in ascending order,
1573/// and the `lo` endpoint second, appending its `hi > r` partners also
1574/// ascending. Both halves are ascending because the input is sorted by `(lo,
1575/// hi)`, and the first half sits entirely below the second because `lo < hi`.
1576///
1577/// Unlike [coo_to_csr] this neither sorts nor merges duplicates, so the caller
1578/// owns both invariants. Violating them produces a *corrupt* CSR rather than a
1579/// panic: unsorted input or `lo > hi` gives descending column indices,
1580/// `lo == hi` duplicates a diagonal entry, and a repeated pair doubles the nnz.
1581/// Every other consumer in the crate assumes ascending deduplicated rows, so
1582/// the invariants are `debug_assert!`ed here and the function stays
1583/// crate-private.
1584///
1585/// ### Params
1586///
1587/// * `n` - Node count; the output is `n` square.
1588/// * `edges` - Deduplicated `(lo, hi, value)` triples with `lo < hi < n`,
1589///   sorted strictly ascending by `(lo, hi)`.
1590///
1591/// ### Returns
1592///
1593/// `CompressedSparseData2` in CSR format with both directions of every edge
1594/// stored at the same value.
1595pub fn undirected_edges_to_csr<T>(n: usize, edges: &[(u32, u32, T)]) -> CompressedSparseData2<T>
1596where
1597    T: BixverseNumeric,
1598{
1599    debug_assert!(
1600        edges
1601            .iter()
1602            .all(|&(lo, hi, _)| lo < hi && (hi as usize) < n),
1603        "undirected_edges_to_csr wants lo < hi < n"
1604    );
1605    debug_assert!(
1606        edges
1607            .windows(2)
1608            .all(|w| (w[0].0, w[0].1) < (w[1].0, w[1].1)),
1609        "undirected_edges_to_csr wants strictly ascending, deduplicated (lo, hi)"
1610    );
1611
1612    let mut indptr = vec![0u32; n + 1];
1613    for &(lo, hi, _) in edges {
1614        indptr[lo as usize + 1] += 1;
1615        indptr[hi as usize + 1] += 1;
1616    }
1617    cumulate_indptr(&mut indptr);
1618
1619    let nnz = 2 * edges.len();
1620    let mut indices = vec![0u32; nnz];
1621    let mut data = vec![T::default(); nnz];
1622    let mut cursor: Vec<u32> = indptr[..n].to_vec();
1623
1624    for &(lo, hi, v) in edges {
1625        let pos = cursor[hi as usize] as usize;
1626        indices[pos] = lo;
1627        data[pos] = v;
1628        cursor[hi as usize] += 1;
1629    }
1630    for &(lo, hi, v) in edges {
1631        let pos = cursor[lo as usize] as usize;
1632        indices[pos] = hi;
1633        data[pos] = v;
1634        cursor[lo as usize] += 1;
1635    }
1636
1637    CompressedSparseData2::from_parts(
1638        data,
1639        indices,
1640        indptr,
1641        None,
1642        CompressedSparseFormat::Csr,
1643        (n, n),
1644    )
1645}
1646
1647///////////////////////
1648// Sparse arithmetic //
1649///////////////////////
1650
1651////////////////////////
1652// Arithmetic: consts //
1653////////////////////////
1654
1655/// Rows merged per rayon job in [merge_csr_rows]. Not tuned; large enough that
1656/// a job outweighs the scheduling cost, small enough to balance skewed rows.
1657const SPARSE_MERGE_ROW_CHUNK: usize = 1024;
1658
1659/// Magnitude at or below which an overlap of two merged entries counts as
1660/// cancelled and is dropped. Stored as `f32` to keep the threshold the
1661/// callers have always seen.
1662const SPARSE_MERGE_EPSILON: f32 = 1e-9;
1663
1664/// Output rows per rayon job in [csr_matmul_csr]. Each job reuses one
1665/// [SparseAccumulator] of the output width, so this also bounds how often the
1666/// accumulator is re-created. Not tuned.
1667const SPARSE_MATMUL_ROW_CHUNK: usize = 256;
1668
1669/// Row or column sum at or below which the L1 normalisation treats the slice as
1670/// empty, to avoid dividing by something indistinguishable from zero.
1671const NORMALISE_MIN_SUM: f64 = 1e-15;
1672
1673/////////////////////////
1674// Arithmetic: structs //
1675/////////////////////////
1676
1677/// Sparse accumulator for efficient sparse matrix multiplication
1678///
1679/// ### Fields
1680///
1681/// * `values` - Vector storing accumulated values for each index
1682/// * `indices` - Vector of active (non-zero) indices
1683/// * `flags` - Boolean flags indicating which indices are active
1684struct SparseAccumulator<T>
1685where
1686    T: Copy + Default + AddAssign,
1687{
1688    values: Vec<T>,
1689    indices: Vec<usize>,
1690    flags: Vec<bool>,
1691}
1692
1693impl<T> SparseAccumulator<T>
1694where
1695    T: Copy + Default + AddAssign,
1696{
1697    /// Create a new sparse accumulator
1698    ///
1699    /// ### Params
1700    ///
1701    /// * `size` - Maximum number of indices to accumulate
1702    fn new(size: usize) -> Self {
1703        Self {
1704            values: vec![T::default(); size],
1705            indices: Vec::with_capacity(size / 10),
1706            flags: vec![false; size],
1707        }
1708    }
1709
1710    /// Add a value to the accumulator at the given index
1711    ///
1712    /// ### Params
1713    ///
1714    /// * `idx` - Index to accumulate at
1715    /// * `val` - Value to add
1716    ///
1717    /// ### Safety
1718    ///
1719    /// `idx` must be less than the size specified during construction
1720    #[inline]
1721    unsafe fn add(&mut self, idx: usize, val: T) {
1722        unsafe {
1723            if !*self.flags.get_unchecked(idx) {
1724                *self.flags.get_unchecked_mut(idx) = true;
1725                self.indices.push(idx);
1726                *self.values.get_unchecked_mut(idx) = val;
1727            } else {
1728                *self.values.get_unchecked_mut(idx) += val;
1729            }
1730        }
1731    }
1732
1733    /// Drain accumulated values into caller-provided buffers and reset the
1734    /// accumulator.
1735    ///
1736    /// ### Params
1737    ///
1738    /// * `indices_out` - Buffer to append active indices into
1739    /// * `data_out` - Buffer to append accumulated values into
1740    ///
1741    /// ### Returns
1742    ///
1743    /// Number of entries written
1744    #[inline]
1745    fn extract_into(&mut self, indices_out: &mut Vec<usize>, data_out: &mut Vec<T>) -> usize {
1746        self.indices.sort_unstable();
1747        let n = self.indices.len();
1748        unsafe {
1749            for &i in &self.indices {
1750                indices_out.push(i);
1751                data_out.push(*self.values.get_unchecked(i));
1752                *self.flags.get_unchecked_mut(i) = false;
1753                *self.values.get_unchecked_mut(i) = T::default();
1754            }
1755        }
1756        self.indices.clear();
1757        n
1758    }
1759}
1760
1761/////////////////////////
1762// Arithmetic: helpers //
1763/////////////////////////
1764
1765/// Check that two CSR matrices can be multiplied, `a @ b`.
1766///
1767/// ### Params
1768///
1769/// * `a` - Left matrix
1770/// * `b` - Right matrix
1771///
1772/// ### Returns
1773///
1774/// `Ok(())`, [SparseErrors::SparseMatrixMustBeCsr] or
1775/// [SparseErrors::SparseMatrixMultiplication] if `a` has a different number of
1776/// columns than `b` has rows.
1777pub fn require_matmul_operands<T>(
1778    a: &CompressedSparseData2<T>,
1779    b: &CompressedSparseData2<T>,
1780) -> Result<(), SparseErrors>
1781where
1782    T: Clone,
1783{
1784    require_csr(a)?;
1785    require_csr(b)?;
1786
1787    let (ncol_a, nrow_b) = (a.shape.1, b.shape.0);
1788    if ncol_a != nrow_b {
1789        return Err(SparseErrors::SparseMatrixMultiplication {
1790            n_col_a: ncol_a,
1791            n_row_b: nrow_b,
1792        });
1793    }
1794    Ok(())
1795}
1796
1797/// Merge two CSR matrices row by row over the union of their patterns.
1798///
1799/// Rows are merged in parallel chunks of [SPARSE_MERGE_ROW_CHUNK]. An entry
1800/// present in only one matrix is carried over (through `lone_b` for `b`); an
1801/// overlap is combined and dropped if the result has magnitude at or below
1802/// [SPARSE_MERGE_EPSILON]. The per-chunk outputs are stitched into one CSR with
1803/// ascending columns per row, assuming both inputs have them.
1804///
1805/// ### Params
1806///
1807/// * `a` - First matrix, CSR, same shape as `b`
1808/// * `b` - Second matrix, CSR, same shape as `a`
1809/// * `combine` - Value of an overlap, given the entries of `a` and `b`
1810/// * `lone_b` - Value of an entry that only `b` stores
1811///
1812/// ### Returns
1813///
1814/// The merged matrix in CSR format.
1815fn merge_csr_rows<T, C, N>(
1816    a: &CompressedSparseData2<T>,
1817    b: &CompressedSparseData2<T>,
1818    combine: C,
1819    lone_b: N,
1820) -> CompressedSparseData2<T>
1821where
1822    T: BixverseNumeric + Into<f64>,
1823    C: Fn(T, T) -> T + Sync,
1824    N: Fn(T) -> T + Sync,
1825{
1826    let n_rows = a.shape.0;
1827
1828    let chunks: Vec<(Vec<u32>, Vec<T>, Vec<u32>)> = (0..n_rows.div_ceil(SPARSE_MERGE_ROW_CHUNK))
1829        .into_par_iter()
1830        .map(|chunk_idx| {
1831            let start = chunk_idx * SPARSE_MERGE_ROW_CHUNK;
1832            let end = (start + SPARSE_MERGE_ROW_CHUNK).min(n_rows);
1833            let cap = (a.indptr[end] - a.indptr[start] + b.indptr[end] - b.indptr[start]) as usize;
1834            let mut cols: Vec<u32> = Vec::with_capacity(cap);
1835            let mut vals: Vec<T> = Vec::with_capacity(cap);
1836            let mut lengths: Vec<u32> = Vec::with_capacity(end - start);
1837
1838            for i in start..end {
1839                let before = cols.len();
1840                let a_end = a.indptr[i + 1] as usize;
1841                let b_end = b.indptr[i + 1] as usize;
1842                let mut a_idx = a.indptr[i] as usize;
1843                let mut b_idx = b.indptr[i] as usize;
1844
1845                while a_idx < a_end && b_idx < b_end {
1846                    let a_col = a.indices[a_idx];
1847                    let b_col = b.indices[b_idx];
1848                    if a_col < b_col {
1849                        cols.push(a_col);
1850                        vals.push(a.data[a_idx]);
1851                        a_idx += 1;
1852                    } else if b_col < a_col {
1853                        cols.push(b_col);
1854                        vals.push(lone_b(b.data[b_idx]));
1855                        b_idx += 1;
1856                    } else {
1857                        let val = combine(a.data[a_idx], b.data[b_idx]);
1858                        if val.into().abs() > SPARSE_MERGE_EPSILON as f64 {
1859                            cols.push(a_col);
1860                            vals.push(val);
1861                        }
1862                        a_idx += 1;
1863                        b_idx += 1;
1864                    }
1865                }
1866                cols.extend_from_slice(&a.indices[a_idx..a_end]);
1867                vals.extend_from_slice(&a.data[a_idx..a_end]);
1868                cols.extend_from_slice(&b.indices[b_idx..b_end]);
1869                vals.extend(b.data[b_idx..b_end].iter().map(|&v| lone_b(v)));
1870
1871                lengths.push((cols.len() - before) as u32);
1872            }
1873
1874            (cols, vals, lengths)
1875        })
1876        .collect();
1877
1878    let nnz: usize = chunks.iter().map(|(c, _, _)| c.len()).sum();
1879    let mut indptr: Vec<u32> = Vec::with_capacity(n_rows + 1);
1880    let mut indices: Vec<u32> = Vec::with_capacity(nnz);
1881    let mut data: Vec<T> = Vec::with_capacity(nnz);
1882    indptr.push(0);
1883    for (cols, vals, lengths) in chunks {
1884        let mut running = *indptr.last().unwrap_or(&0);
1885        for len in lengths {
1886            running += len;
1887            indptr.push(running);
1888        }
1889        indices.extend_from_slice(&cols);
1890        data.extend_from_slice(&vals);
1891    }
1892
1893    CompressedSparseData2::from_parts(
1894        data,
1895        indices,
1896        indptr,
1897        None,
1898        CompressedSparseFormat::Csr,
1899        a.shape,
1900    )
1901}
1902
1903//////////////////////////////////
1904// Arithmetic: public functions //
1905//////////////////////////////////
1906
1907/// Add two CSR matrices together
1908///
1909/// See [merge_csr_rows] for the merge itself; an overlap whose sum has
1910/// magnitude at or below [SPARSE_MERGE_EPSILON] is dropped.
1911///
1912/// ### Params
1913///
1914/// * `a` - Reference to the first CompressedSparseData2 (in CSR format!)
1915/// * `b` - Reference to the second CompressedSparseData2 (in CSR format!)
1916///
1917/// ### Returns
1918///
1919/// `CompressedSparseData2` with added values between the two.
1920pub fn sparse_add_csr<T>(
1921    a: &CompressedSparseData2<T>,
1922    b: &CompressedSparseData2<T>,
1923) -> Result<CompressedSparseData2<T>, SparseErrors>
1924where
1925    T: BixverseNumeric + Into<f64> + Add<Output = T>,
1926{
1927    require_csr_pair_same_shape(a, b)?;
1928    Ok(merge_csr_rows(a, b, |x, y| x + y, |y| y))
1929}
1930
1931/// Scalar multiplication of CSR matrix
1932///
1933/// ### Params
1934///
1935/// * `a` - Reference to the first CompressedSparseData2 (in CSR format!)
1936/// * `scalar` - The scalar value to multiply with
1937///
1938/// ### Returns
1939///
1940/// `CompressedSparseData2` with the data multiplied by the scalar.
1941pub fn sparse_scalar_multiply_csr<T>(
1942    a: &CompressedSparseData2<T>,
1943    scalar: T,
1944) -> CompressedSparseData2<T>
1945where
1946    T: BixverseNumeric,
1947    <T as Mul>::Output: Send,
1948    Vec<T>: FromParallelIterator<<T as Mul>::Output>,
1949{
1950    let data: Vec<T> = a.data.par_iter().map(|&v| v * scalar).collect();
1951    CompressedSparseData2::new_csr(&data, &a.indices, &a.indptr, None, a.shape)
1952}
1953
1954/// Sparse matrix subtraction
1955///
1956/// `a - b`, merged as for [sparse_add_csr]; an overlap whose difference has
1957/// magnitude at or below [SPARSE_MERGE_EPSILON] is dropped.
1958///
1959/// ### Params
1960///
1961/// * `a` - Reference to the first CompressedSparseData2 (in CSR format!)
1962/// * `b` - Reference to the second CompressedSparseData2 (in CSR format!)
1963///
1964/// ### Returns
1965///
1966/// The subtracted new matrix
1967pub fn sparse_subtract_csr<T>(
1968    a: &CompressedSparseData2<T>,
1969    b: &CompressedSparseData2<T>,
1970) -> Result<CompressedSparseData2<T>, SparseErrors>
1971where
1972    T: BixverseNumeric + Into<f64>,
1973{
1974    require_csr_pair_same_shape(a, b)?;
1975    Ok(merge_csr_rows(a, b, |x, y| x - y, |y| T::default() - y))
1976}
1977
1978/// Element-wise sparse multiplication
1979///
1980/// ### Params
1981///
1982/// * `a` - Reference to the first CompressedSparseData2 (in CSR format!)
1983/// * `b` - Reference to the second CompressedSparseData2 (in CSR format!)
1984///
1985/// ### Returns
1986///
1987/// The multiplied matrix.
1988pub fn sparse_multiply_elementwise_csr<T>(
1989    a: &CompressedSparseData2<T>,
1990    b: &CompressedSparseData2<T>,
1991) -> Result<CompressedSparseData2<T>, SparseErrors>
1992where
1993    T: BixverseNumeric,
1994    <T as std::ops::Add>::Output: std::cmp::PartialEq<T>,
1995{
1996    require_csr_pair_same_shape(a, b)?;
1997
1998    let n_rows = a.shape.0;
1999    let mut rows: Vec<u32> = Vec::new();
2000    let mut cols: Vec<u32> = Vec::new();
2001    let mut vals = Vec::new();
2002    for i in 0..n_rows {
2003        let a_start = a.indptr[i] as usize;
2004        let a_end = a.indptr[i + 1] as usize;
2005        let b_start = b.indptr[i] as usize;
2006        let b_end = b.indptr[i + 1] as usize;
2007        let mut a_idx = a_start;
2008        let mut b_idx = b_start;
2009        while a_idx < a_end && b_idx < b_end {
2010            match a.indices[a_idx].cmp(&b.indices[b_idx]) {
2011                std::cmp::Ordering::Less => {
2012                    a_idx += 1;
2013                }
2014                std::cmp::Ordering::Greater => {
2015                    b_idx += 1;
2016                }
2017                std::cmp::Ordering::Equal => {
2018                    // Same column - multiply
2019                    let val = a.data[a_idx] * b.data[b_idx];
2020                    if val != T::default() {
2021                        rows.push(i as u32);
2022                        cols.push(a.indices[a_idx]);
2023                        vals.push(val);
2024                    }
2025                    a_idx += 1;
2026                    b_idx += 1;
2027                }
2028            }
2029        }
2030    }
2031
2032    Ok(coo_to_csr(&rows, &cols, &vals, a.shape))
2033}
2034
2035/// Normalises the columns of a CSR matrix to a sum of 1 (L1 norm)
2036///
2037/// ### Params
2038///
2039/// * `csr` - Mutable reference to the CSR matrix (modified in-place)
2040pub fn normalise_csr_columns_l1<T>(csr: &mut CompressedSparseData2<T>)
2041where
2042    T: BixverseNumeric + Into<f64>,
2043    <T as std::ops::Add>::Output: std::cmp::PartialEq<T>,
2044{
2045    assert!(csr.cs_type.is_csr(), "Matrix must be in CSR format");
2046
2047    let ncols = csr.shape.1;
2048
2049    let mut col_sums = vec![T::default(); ncols];
2050
2051    for (idx, &col) in csr.indices.iter().enumerate() {
2052        col_sums[col as usize] += csr.data[idx]
2053    }
2054
2055    for (idx, &col) in csr.indices.iter().enumerate() {
2056        let sum = col_sums[col as usize];
2057        if sum.into() > NORMALISE_MIN_SUM {
2058            csr.data[idx] /= sum;
2059        }
2060    }
2061}
2062
2063/// Normalises the rows of a CSR matrix to a sum of 1 (L1 norm)
2064///
2065/// Turns an affinity matrix `K` into the row-stochastic diffusion operator
2066/// `D^-1 K` in place.
2067///
2068/// A row summing to zero is an error rather than a no-op. The reference
2069/// implementations this backs (Palantir's `diffusion_maps_from_kernel`, MAGIC)
2070/// guard with `D[D != 0] = 1 / D[D != 0]` and leave the row at zero, but on a
2071/// kNN-derived kernel every node has `k` out-edges, so a zero row means the
2072/// weights underflowed and everything downstream of the operator is garbage.
2073/// Failing loudly beats propagating a silently absorbing state.
2074///
2075/// ### Params
2076///
2077/// * `csr` - Mutable reference to the CSR matrix (modified in-place)
2078///
2079/// ### Returns
2080///
2081/// `Ok(())`, or [SparseErrors::SparseMatrixMustBeCsr] if the matrix is in
2082/// CSC, or [SparseErrors::SparseMatrixIsolatedRow] naming the first row that
2083/// sums to zero.
2084pub fn normalise_csr_rows_l1<T>(csr: &mut CompressedSparseData2<T>) -> Result<(), SparseErrors>
2085where
2086    T: BixverseNumeric + Into<f64>,
2087    T: std::iter::Sum<T>,
2088{
2089    require_csr(csr)?;
2090
2091    let nrows = csr.shape.0;
2092
2093    for i in 0..nrows {
2094        let start = csr.indptr[i] as usize;
2095        let end = csr.indptr[i + 1] as usize;
2096        let row_data_slice = &mut csr.data[start..end];
2097
2098        let row_sum: T = row_data_slice.iter().copied().sum();
2099
2100        if row_sum.into() <= NORMALISE_MIN_SUM {
2101            return Err(SparseErrors::SparseMatrixIsolatedRow {
2102                row: i,
2103                row_sum: row_sum.into(),
2104            });
2105        }
2106
2107        for val in row_data_slice.iter_mut() {
2108            *val /= row_sum;
2109        }
2110    }
2111
2112    Ok(())
2113}
2114
2115/// Remove zeros from sparse matrix
2116///
2117/// ### Params
2118///
2119/// * `mat` - Matrix from which to remove the zeroes
2120///
2121/// ### Returns
2122///
2123/// The Matrix with 0's removed.
2124pub fn eliminate_zeros_csr<T>(mat: CompressedSparseData2<T>) -> CompressedSparseData2<T>
2125where
2126    T: BixverseNumeric,
2127    <T as std::ops::Add>::Output: std::cmp::PartialEq<T>,
2128{
2129    let mut rows: Vec<u32> = Vec::new();
2130    let mut cols: Vec<u32> = Vec::new();
2131    let mut vals = Vec::new();
2132
2133    let n_rows = mat.shape.0;
2134    for i in 0..n_rows {
2135        let start = mat.indptr[i] as usize;
2136        let end = mat.indptr[i + 1] as usize;
2137
2138        for j in start..end {
2139            if mat.data[j] != T::default() {
2140                rows.push(i as u32);
2141                cols.push(mat.indices[j]);
2142                vals.push(mat.data[j]);
2143            }
2144        }
2145    }
2146
2147    coo_to_csr(&rows, &cols, &vals, mat.shape)
2148}
2149
2150/// Sparse matrix @ dense vector: `M @ v`.
2151///
2152/// Computes `result[i] = sum_j M[i, j] * v[j]` by iterating CSR rows.
2153///
2154/// ### Params
2155///
2156/// * `mat` - CSR matrix of shape `(m, n)`.
2157/// * `vec` - Dense vector of length `n`.
2158///
2159/// ### Returns
2160///
2161/// Dense vector of length `m`. Errors if `mat` is not CSR.
2162pub fn csr_matvec<T>(mat: &CompressedSparseData2<T>, vec: &[T]) -> Result<Vec<T>, SparseErrors>
2163where
2164    T: BixverseNumeric,
2165    <T as std::ops::Add>::Output: std::cmp::PartialEq<T>,
2166{
2167    require_csr(mat)?;
2168
2169    let mut result = vec![T::default(); mat.shape.0];
2170    for i in 0..mat.shape.0 {
2171        let row_start = mat.indptr[i] as usize;
2172        let row_end = mat.indptr[i + 1] as usize;
2173        let data = &mat.data[row_start..row_end];
2174        let cols = &mat.indices[row_start..row_end];
2175        let mut acc = [T::default(); 4];
2176        let mut data_chunks = data.chunks_exact(4);
2177        let mut col_chunks = cols.chunks_exact(4);
2178        for (d, c) in (&mut data_chunks).zip(&mut col_chunks) {
2179            for l in 0..4 {
2180                acc[l] += d[l] * vec[c[l] as usize];
2181            }
2182        }
2183        let mut sum = (acc[0] + acc[1]) + (acc[2] + acc[3]);
2184        for (&d, &c) in data_chunks.remainder().iter().zip(col_chunks.remainder()) {
2185            sum += d * vec[c as usize];
2186        }
2187        result[i] = sum;
2188    }
2189
2190    Ok(result)
2191}
2192
2193/// Dense vector @ sparse matrix: `v @ M`.
2194///
2195/// Computes `result[j] = sum_i v[i] * M[i, j]` by iterating CSR rows of `M`
2196/// and scattering scaled row contributions into the output. Treats `v` as a
2197/// row vector.
2198///
2199/// ### Params
2200///
2201/// * `vec` - Dense vector of length `m`.
2202/// * `mat` - CSR matrix of shape `(m, n)`.
2203///
2204/// ### Returns
2205///
2206/// Dense vector of length `n`. Errors if `mat` is not CSR.
2207pub fn csr_vecmat<T>(vec: &[T], mat: &CompressedSparseData2<T>) -> Result<Vec<T>, SparseErrors>
2208where
2209    T: BixverseNumeric,
2210{
2211    require_csr(mat)?;
2212
2213    let n_cols = mat.ncols();
2214    let mut out = vec![T::default(); n_cols];
2215    for (j, &vj) in vec.iter().enumerate() {
2216        if vj == T::default() {
2217            continue;
2218        }
2219        let gs = mat.indptr[j] as usize;
2220        let ge = mat.indptr[j + 1] as usize;
2221        for q in gs..ge {
2222            let t = mat.indices[q] as usize;
2223            out[t] += vj * mat.data[q];
2224        }
2225    }
2226
2227    Ok(out)
2228}
2229
2230/// Multiply two CSR matrices using sparse accumulators and parallel processing
2231///
2232/// ### Params
2233///
2234/// * `a` - Left CSR matrix
2235/// * `b` - Right CSR matrix
2236///
2237/// ### Returns
2238///
2239/// Product matrix in CSR format
2240pub fn csr_matmul_csr<T>(
2241    a: &CompressedSparseData2<T>,
2242    b: &CompressedSparseData2<T>,
2243) -> Result<CompressedSparseData2<T>, SparseErrors>
2244where
2245    T: BixverseNumeric,
2246{
2247    require_matmul_operands(a, b)?;
2248
2249    let nrows = a.shape.0;
2250    let ncols = b.shape.1;
2251
2252    let n_chunks = nrows.div_ceil(SPARSE_MATMUL_ROW_CHUNK);
2253
2254    let chunks: Vec<(Vec<usize>, Vec<T>, Vec<usize>)> = (0..n_chunks)
2255        .into_par_iter()
2256        .map_init(
2257            || SparseAccumulator::new(ncols),
2258            |acc, chunk_idx| {
2259                let start = chunk_idx * SPARSE_MATMUL_ROW_CHUNK;
2260                let end = ((chunk_idx + 1) * SPARSE_MATMUL_ROW_CHUNK).min(nrows);
2261
2262                let mut idx_buf = Vec::new();
2263                let mut data_buf = Vec::new();
2264                let mut lengths = Vec::with_capacity(end - start);
2265
2266                for i in start..end {
2267                    for a_idx in a.indptr[i] as usize..a.indptr[i + 1] as usize {
2268                        let k = a.indices[a_idx] as usize;
2269                        let a_val = a.data[a_idx];
2270                        for b_idx in b.indptr[k] as usize..b.indptr[k + 1] as usize {
2271                            unsafe {
2272                                acc.add(b.indices[b_idx] as usize, a_val * b.data[b_idx]);
2273                            }
2274                        }
2275                    }
2276                    lengths.push(acc.extract_into(&mut idx_buf, &mut data_buf));
2277                }
2278
2279                (idx_buf, data_buf, lengths)
2280            },
2281        )
2282        .collect();
2283
2284    let total_nnz: usize = chunks.iter().map(|(i, _, _)| i.len()).sum();
2285
2286    let mut indptr: Vec<u32> = Vec::with_capacity(nrows + 1);
2287    indptr.push(0);
2288    let mut running = 0usize;
2289    for (_, _, lengths) in &chunks {
2290        for &len in lengths {
2291            running += len;
2292            indptr.push(running as u32);
2293        }
2294    }
2295
2296    let mut indices: Vec<u32> = Vec::with_capacity(total_nnz);
2297    let mut data = Vec::with_capacity(total_nnz);
2298    for (idx_buf, data_buf, _) in chunks {
2299        indices.extend(idx_buf.into_iter().map(|x| x as u32));
2300        data.extend(data_buf);
2301    }
2302
2303    // build directly rather than via new_csr, which would .to_vec() the lot.
2304    Ok(CompressedSparseData2 {
2305        data,
2306        indices,
2307        indptr,
2308        cs_type: CompressedSparseFormat::Csr,
2309        data_2: None,
2310        shape: (nrows, ncols),
2311    })
2312}
2313
2314/////////////////////////////
2315
2316///////////
2317// Tests //
2318///////////
2319
2320#[cfg(test)]
2321mod tests {
2322    use super::*;
2323    use approx::assert_relative_eq;
2324
2325    ////////////////
2326    // validate() //
2327    ////////////////
2328
2329    /// A 3 x 4 CSR with one empty row, both layers populated.
2330    fn validatable_csr() -> CompressedSparseData2<f32, f32> {
2331        CompressedSparseData2 {
2332            data: vec![1.0, 2.0, 3.0, 4.0],
2333            indices: vec![0, 3, 1, 2],
2334            indptr: vec![0, 2, 2, 4],
2335            cs_type: CompressedSparseFormat::Csr,
2336            data_2: Some(vec![10.0, 20.0, 30.0, 40.0]),
2337            shape: (3, 4),
2338        }
2339    }
2340
2341    #[test]
2342    fn test_validate_accepts_a_well_formed_matrix() {
2343        assert!(validatable_csr().validate().is_ok());
2344        assert!(validatable_csr().transform().validate().is_ok());
2345    }
2346
2347    /// The single-layer transform leaves `data` empty on purpose, so an empty
2348    /// layer must read as absent rather than as a length mismatch.
2349    #[test]
2350    fn test_validate_accepts_an_empty_unused_layer() {
2351        let single = validatable_csr().transform_single_layer(true).unwrap();
2352
2353        assert!(single.data.is_empty());
2354        assert!(single.validate().is_ok());
2355    }
2356
2357    /// An indptr that disagrees with the declared major axis would otherwise
2358    /// silently process fewer rows than the shape claims.
2359    #[test]
2360    fn test_validate_rejects_a_short_indptr() {
2361        let mut m = validatable_csr();
2362        m.indptr = vec![0, 2, 4];
2363
2364        assert!(matches!(
2365            m.validate(),
2366            Err(SparseErrors::SparseIndptrInvalid {
2367                expected: 4,
2368                got: 3,
2369                ..
2370            })
2371        ));
2372    }
2373
2374    #[test]
2375    fn test_validate_rejects_a_final_offset_past_the_indices() {
2376        let mut m = validatable_csr();
2377        m.indptr = vec![0, 2, 2, 9];
2378
2379        assert!(matches!(
2380            m.validate(),
2381            Err(SparseErrors::SparseIndptrInvalid {
2382                expected: 4,
2383                got: 9,
2384                ..
2385            })
2386        ));
2387    }
2388
2389    #[test]
2390    fn test_validate_rejects_decreasing_offsets() {
2391        let mut m = validatable_csr();
2392        m.indptr = vec![0, 3, 1, 4];
2393
2394        assert!(matches!(
2395            m.validate(),
2396            Err(SparseErrors::SparseIndptrInvalid { .. })
2397        ));
2398    }
2399
2400    /// This is the one that matters: an index past the minor axis is what turns
2401    /// a dense scatter into an out-of-bounds panic in a worker thread.
2402    #[test]
2403    fn test_validate_rejects_an_index_past_the_minor_axis() {
2404        let mut m = validatable_csr();
2405        m.indices = vec![0, 3, 1, 4];
2406
2407        assert!(matches!(
2408            m.validate(),
2409            Err(SparseErrors::SliceIndexOutOfBounds { index: 4, len: 4 })
2410        ));
2411    }
2412
2413    /// CSC bounds the indices by the row count, not the column count, so the
2414    /// same index list is fine in one orientation and not the other.
2415    #[test]
2416    fn test_validate_bounds_csc_by_the_row_count() {
2417        let mut m = validatable_csr();
2418        m.cs_type = CompressedSparseFormat::Csc;
2419        m.shape = (4, 3);
2420
2421        // indices max to 3, rows are 4: fine
2422        assert!(m.validate().is_ok());
2423
2424        m.shape = (3, 3);
2425        assert!(matches!(
2426            m.validate(),
2427            Err(SparseErrors::SliceIndexOutOfBounds { index: 3, len: 3 })
2428        ));
2429    }
2430
2431    #[test]
2432    fn test_validate_rejects_a_truncated_second_layer() {
2433        let mut m = validatable_csr();
2434        m.data_2 = Some(vec![10.0, 20.0]);
2435
2436        assert!(matches!(
2437            m.validate(),
2438            Err(SparseErrors::DimensionMisMatchSparse {
2439                indices_len: 4,
2440                data_len: 2
2441            })
2442        ));
2443    }
2444
2445    /// The format parser takes either case and rejects anything that is not CSR or CSC.
2446    #[test]
2447    fn test_parse_sparse_format() {
2448        assert!(parse_compressed_sparse_format("csr").unwrap().is_csr());
2449        assert!(parse_compressed_sparse_format("CSC").unwrap().is_csc());
2450        assert!(parse_compressed_sparse_format("dense").is_none());
2451    }
2452
2453    /// Five rows, both layers populated, and one empty row so the chunk splits
2454    /// land on an empty `indptr` span at least once.
2455    fn transpose_fixture() -> CompressedSparseData2<f64, f64> {
2456        let data: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
2457        let data2: Vec<f64> = data.iter().map(|v| v * 10.0).collect();
2458        let indices: Vec<u32> = vec![0, 2, 1, 0, 1, 3, 2, 3];
2459        let indptr: Vec<u32> = vec![0, 2, 3, 3, 6, 8];
2460
2461        CompressedSparseData2::new_csr(&data, &indices, &indptr, Some(&data2), (5, 4))
2462    }
2463
2464    /// The parallel path only differs from the serial one in how the old major
2465    /// axis is split, so forcing an uneven multi-chunk split is what exercises
2466    /// the per-chunk cursors without building a matrix past
2467    /// `PARALLEL_TRANSPOSE_MIN_NNZ`.
2468    #[test]
2469    fn test_transpose_chunked_matches_single_chunk() {
2470        let csr = transpose_fixture();
2471
2472        let serial = transpose_sparse_chunked(&csr, &[(0, 5)], usize::MAX);
2473
2474        for (chunks, band) in [
2475            (vec![(0, 1), (1, 3), (3, 5)], usize::MAX),
2476            (vec![(0, 2), (2, 5)], usize::MAX),
2477            (vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)], usize::MAX),
2478            (vec![(0, 5)], 1),
2479            (vec![(0, 2), (2, 5)], 1),
2480            (vec![(0, 1), (1, 3), (3, 5)], 2),
2481            (vec![(0, 2), (2, 5)], 3),
2482            (vec![(0, 2), (2, 5)], 4),
2483        ] {
2484            let parallel = transpose_sparse_chunked(&csr, &chunks, band);
2485
2486            assert_eq!(parallel.indptr, serial.indptr);
2487            assert_eq!(parallel.indices, serial.indices);
2488            assert_eq!(parallel.data, serial.data);
2489            assert_eq!(parallel.data_2, serial.data_2);
2490            assert_eq!(parallel.cs_type.is_csc(), serial.cs_type.is_csc());
2491            assert_eq!(parallel.shape, serial.shape);
2492        }
2493    }
2494
2495    /// A run with unsorted minor indices cannot be banded; the scatter falls
2496    /// back to the unbanded walk and still places every entry.
2497    #[test]
2498    fn test_transpose_unsorted_runs_ignore_band() {
2499        let data: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0];
2500        let indices: Vec<u32> = vec![2, 0, 3, 1, 0];
2501        let indptr: Vec<u32> = vec![0, 2, 4, 5];
2502        let csr =
2503            CompressedSparseData2::<f64, f64>::new_csr(&data, &indices, &indptr, None, (3, 4));
2504
2505        let csc = transpose_sparse_chunked(&csr, &[(0, 1), (1, 3)], 1);
2506        assert_eq!(csc.indptr, vec![0, 2, 3, 4, 5]);
2507        assert_eq!(csc.indices, vec![0, 2, 1, 0, 1]);
2508        assert_eq!(csc.data, vec![2.0, 5.0, 4.0, 1.0, 3.0]);
2509    }
2510
2511    /// Round-tripping has to give the input back, and the transposed layout has
2512    /// to be ascending within every new major index.
2513    #[test]
2514    fn test_transpose_round_trips_and_stays_sorted() {
2515        let csr = transpose_fixture();
2516        let csc = transpose_sparse(&csr);
2517
2518        assert!(csc.cs_type.is_csc());
2519        for major in 0..csc.indptr.len() - 1 {
2520            let lo = csc.indptr[major] as usize;
2521            let hi = csc.indptr[major + 1] as usize;
2522            assert!(csc.indices[lo..hi].windows(2).all(|w| w[0] < w[1]));
2523        }
2524
2525        let round_trip = transpose_sparse(&csc);
2526        assert_eq!(round_trip.indptr, csr.indptr);
2527        assert_eq!(round_trip.indices, csr.indices);
2528        assert_eq!(round_trip.data, csr.data);
2529        assert_eq!(round_trip.data_2, csr.data_2);
2530    }
2531
2532    /// The single-layer variant drops the layer it was not asked for and keeps
2533    /// the other one identical to the two-layer transpose.
2534    #[test]
2535    fn test_transpose_single_layer_matches_full_transpose() {
2536        let csr = transpose_fixture();
2537        let full = transpose_sparse(&csr);
2538
2539        let counts_only = csr.transform_single_layer(false).unwrap();
2540        assert_eq!(counts_only.data, full.data);
2541        assert!(counts_only.data_2.is_none());
2542
2543        let norm_only = csr.transform_single_layer(true).unwrap();
2544        assert_eq!(norm_only.data_2, full.data_2);
2545        assert!(norm_only.data.is_empty());
2546
2547        assert_eq!(counts_only.indptr, full.indptr);
2548        assert_eq!(norm_only.indices, full.indices);
2549    }
2550
2551    /// Both endpoints of every edge land in the matrix and every row stays ascending.
2552    #[test]
2553    fn test_undirected_edges_to_csr_is_symmetric_and_row_sorted() {
2554        // A path plus one chord, so several rows hold both a `lo < r` partner
2555        // and a `hi > r` one. That split is the whole trick behind the
2556        // sort-free scatter, and it is what breaks first if the two passes are
2557        // ever reordered.
2558        let edges = vec![(0u32, 1u32, 1.0f64), (0, 3, 2.0), (1, 2, 3.0), (2, 3, 4.0)];
2559
2560        let csr = undirected_edges_to_csr(4, &edges);
2561
2562        assert_eq!(csr.shape, (4, 4));
2563        assert_eq!(csr.indptr, vec![0, 2, 4, 6, 8]);
2564        assert_eq!(csr.indices, vec![1, 3, 0, 2, 1, 3, 0, 2]);
2565        assert_eq!(csr.data, vec![1.0, 2.0, 1.0, 3.0, 3.0, 4.0, 2.0, 4.0]);
2566
2567        // Every row ascending, which every consumer in the crate assumes.
2568        for i in 0..4 {
2569            let lo = csr.indptr[i] as usize;
2570            let hi = csr.indptr[i + 1] as usize;
2571            assert!(csr.indices[lo..hi].windows(2).all(|w| w[0] < w[1]));
2572        }
2573    }
2574
2575    /// No edges must still produce a well-formed `indptr` of the declared size.
2576    #[test]
2577    fn test_undirected_edges_to_csr_handles_empty_input() {
2578        let csr = undirected_edges_to_csr::<f64>(3, &[]);
2579
2580        assert_eq!(csr.shape, (3, 3));
2581        assert_eq!(csr.indptr, vec![0, 0, 0, 0]);
2582        assert!(csr.indices.is_empty());
2583    }
2584
2585    /// An edgeless node keeps an empty span instead of shifting every later row.
2586    #[test]
2587    fn test_undirected_edges_to_csr_keeps_isolated_nodes() {
2588        // Node 0 has no edges at all, so its span has to be empty rather than
2589        // shifting every later row.
2590        let edges = vec![(1u32, 2u32, 7.0f64)];
2591
2592        let csr = undirected_edges_to_csr(3, &edges);
2593
2594        assert_eq!(csr.indptr, vec![0, 0, 1, 2]);
2595        assert_eq!(csr.indices, vec![2, 1]);
2596    }
2597
2598    /// A well-formed square CSR passes and reports its dimension.
2599    #[test]
2600    fn test_validate_square_csr_accepts_a_sound_matrix() {
2601        let csr = undirected_edges_to_csr(3, &[(0u32, 1u32, 1.0f64), (1, 2, 2.0)]);
2602
2603        assert_eq!(validate_square_csr(&csr).unwrap(), 3);
2604    }
2605
2606    /// Each structural fault the validator exists for must come back as `MalformedCsr`.
2607    #[test]
2608    fn test_validate_square_csr_catches_structural_faults() {
2609        // Non-monotonic indptr: the Rust range `5..2` is empty, so row 1 is
2610        // silently skipped rather than read.
2611        let bad_indptr = CompressedSparseData2::new_csr(
2612            &[1.0f64; 7],
2613            &[1u32, 2, 0, 2, 0, 0, 1],
2614            &[0u32, 5, 2, 7],
2615            None,
2616            (3, 3),
2617        );
2618        assert!(matches!(
2619            validate_square_csr(&bad_indptr),
2620            Err(SparseErrors::MalformedCsr(_))
2621        ));
2622
2623        // A column index past the last row indexes whatever the caller keys off
2624        // it out of bounds.
2625        let bad_index = CompressedSparseData2::new_csr(
2626            &[1.0f64, 1.0],
2627            &[1u32, 9],
2628            &[0u32, 1, 2, 2],
2629            None,
2630            (3, 3),
2631        );
2632        assert!(matches!(
2633            validate_square_csr(&bad_index),
2634            Err(SparseErrors::MalformedCsr(_))
2635        ));
2636
2637        // indptr of the wrong length for the declared row count.
2638        let short_indptr =
2639            CompressedSparseData2::new_csr(&[1.0f64], &[1u32], &[0u32, 1], None, (3, 3));
2640        assert!(matches!(
2641            validate_square_csr(&short_indptr),
2642            Err(SparseErrors::MalformedCsr(_))
2643        ));
2644
2645        // The last pointer must account for every stored value.
2646        let short_tail = CompressedSparseData2::new_csr(
2647            &[1.0f64, 1.0],
2648            &[1u32, 0],
2649            &[0u32, 1, 1, 1],
2650            None,
2651            (3, 3),
2652        );
2653        assert!(matches!(
2654            validate_square_csr(&short_tail),
2655            Err(SparseErrors::MalformedCsr(_))
2656        ));
2657    }
2658    /// Adding two CSR matrices merges shared columns rather than storing them twice.
2659    #[test]
2660    fn test_sparse_add_csr() {
2661        let shape = (2, 2);
2662        let a = CompressedSparseData2::<f64, f64>::new_csr(
2663            &[1.0, 2.0],
2664            &[0, 1],
2665            &[0, 1, 2],
2666            None,
2667            shape,
2668        );
2669        let b = CompressedSparseData2::<f64, f64>::new_csr(
2670            &[3.0, 4.0],
2671            &[1, 1],
2672            &[0, 1, 2],
2673            None,
2674            shape,
2675        );
2676        let c = sparse_add_csr(&a, &b).unwrap();
2677
2678        assert_eq!(c.data, vec![1.0, 3.0, 6.0]);
2679        assert_eq!(c.indices, vec![0, 1, 1]);
2680        assert_eq!(c.indptr, vec![0, 2, 3]);
2681    }
2682
2683    /// CSR matrix-vector product on a case small enough to check by hand.
2684    #[test]
2685    fn test_csr_matvec() {
2686        let a = CompressedSparseData2::<f64, f64>::new_csr(
2687            &[1.0, 2.0, 3.0],
2688            &[0, 1, 1],
2689            &[0, 2, 3],
2690            None,
2691            (2, 2),
2692        );
2693        let vec = vec![2.0, 1.0];
2694        let result = csr_matvec(&a, &vec).unwrap();
2695        assert_eq!(result, vec![4.0, 3.0]);
2696    }
2697
2698    ///////////////////////////
2699    // Row-stochastic + axpy //
2700    ///////////////////////////
2701
2702    /// A row that sums to zero must come back as an error rather than a panic.
2703    #[test]
2704    fn test_normalise_csr_rows_l1_isolated_row_errors() {
2705        // Row 1 is structurally empty, row 2 stores an explicit zero. Both are
2706        // isolated as far as a row-stochastic normalisation is concerned.
2707        let data = vec![1.0f64, 3.0, 0.0];
2708        let indices = vec![0u32, 2, 1];
2709        let indptr = vec![0u32, 2, 2, 3];
2710        let mut csr = CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (3, 3));
2711
2712        match normalise_csr_rows_l1(&mut csr) {
2713            Err(SparseErrors::SparseMatrixIsolatedRow { row, row_sum }) => {
2714                assert_eq!(row, 1);
2715                assert_eq!(row_sum, 0.0);
2716            }
2717            other => panic!("expected SparseMatrixIsolatedRow, got {:?}", other),
2718        }
2719    }
2720
2721    /// The happy path has to leave every row summing to exactly one.
2722    #[test]
2723    fn test_normalise_csr_rows_l1_rows_sum_to_one() {
2724        let data = vec![1.0f64, 3.0, 2.0, 2.0, 5.0];
2725        let indices = vec![0u32, 2, 1, 2, 0];
2726        let indptr = vec![0u32, 2, 4, 5];
2727        let mut csr = CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (3, 3));
2728
2729        normalise_csr_rows_l1(&mut csr).unwrap();
2730
2731        for i in 0..3 {
2732            let lo = csr.indptr[i] as usize;
2733            let hi = csr.indptr[i + 1] as usize;
2734            let sum: f64 = csr.data[lo..hi].iter().sum();
2735            assert_relative_eq!(sum, 1.0, epsilon = 1e-12);
2736        }
2737        assert_relative_eq!(csr.data[0], 0.25, epsilon = 1e-12);
2738        assert_relative_eq!(csr.data[1], 0.75, epsilon = 1e-12);
2739    }
2740}