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