Skip to main content

oxicuda_sparse/format/
hyb.rs

1//! HYB (Hybrid ELL+COO) sparse matrix format.
2//!
3//! The HYB format splits a sparse matrix into two portions:
4//! - An **ELL** (ELLPACK) portion storing the regular part: up to `ell_width`
5//!   entries per row, padded with sentinel values (-1) for unused slots.
6//! - A **COO** (Coordinate) overflow portion storing entries that exceed the
7//!   ELL width.
8//!
9//! This hybrid approach combines the coalesced memory access benefits of ELL
10//! (for rows with typical density) with the flexibility of COO (for rows
11//! with atypical density), yielding better performance than either format
12//! alone on matrices with irregular sparsity patterns.
13//!
14//! The [`HybPartition`] enum controls how the ELL width is chosen:
15//! - `Auto` uses the median row nnz (good default).
16//! - `Max` uses the maximum row nnz (pure ELL, no COO overflow).
17//! - `Threshold(f64)` uses a percentile of the row nnz distribution.
18//! - `Fixed(usize)` lets the user specify the ELL width directly.
19
20use std::mem;
21
22use oxicuda_blas::GpuFloat;
23
24use crate::error::{SparseError, SparseResult};
25use crate::format::CsrMatrix;
26
27/// Sentinel value for unused ELL entries in the HYB format.
28pub const HYB_ELL_SENTINEL: i32 = -1;
29
30/// Strategy for partitioning non-zeros between ELL and COO portions.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub enum HybPartition {
33    /// Automatic: use the median nnz per row as the ELL width.
34    Auto,
35    /// Maximum: use the max nnz per row (pure ELL, zero COO overflow).
36    Max,
37    /// Percentile threshold: use the given percentile (0.0--1.0) of the
38    /// per-row nnz distribution as the ELL width. For example, 0.9 means
39    /// the 90th percentile.
40    Threshold(f64),
41    /// Fixed ELL width chosen by the user.
42    Fixed(usize),
43}
44
45/// A sparse matrix in HYB (Hybrid ELL+COO) format, stored on host.
46///
47/// The ELL portion uses column-major layout: element `(row, k)` is at index
48/// `k * rows + row`. Unused ELL slots have column index [`HYB_ELL_SENTINEL`]
49/// and a zero value.
50///
51/// The COO portion stores overflow entries as unsorted `(row, col, value)`
52/// triplets.
53#[derive(Debug, Clone)]
54pub struct HybMatrix<T: GpuFloat> {
55    /// Number of rows in the matrix.
56    rows: usize,
57    /// Number of columns in the matrix.
58    cols: usize,
59    /// ELL width: maximum entries stored per row in the ELL portion.
60    ell_width: usize,
61    /// ELL column indices, length `rows * ell_width`, column-major.
62    /// Unused entries are [`HYB_ELL_SENTINEL`] (-1).
63    ell_col_indices: Vec<i32>,
64    /// ELL values, length `rows * ell_width`, column-major.
65    /// Unused entries are zero.
66    ell_values: Vec<T>,
67    /// Number of overflow entries stored in the COO portion.
68    coo_nnz: usize,
69    /// COO overflow row indices, length `coo_nnz`.
70    coo_row_indices: Vec<i32>,
71    /// COO overflow column indices, length `coo_nnz`.
72    coo_col_indices: Vec<i32>,
73    /// COO overflow values, length `coo_nnz`.
74    coo_values: Vec<T>,
75}
76
77/// Statistics describing the quality of the HYB partition.
78#[derive(Debug, Clone, Copy)]
79pub struct HybStatistics {
80    /// Fraction of total nnz stored in the ELL portion (0.0--1.0).
81    pub ell_fraction: f64,
82    /// Fraction of total nnz stored in the COO portion (0.0--1.0).
83    pub coo_fraction: f64,
84    /// Ratio of wasted (padding) slots in the ELL portion.
85    /// Computed as `(ell_total_slots - ell_nnz) / ell_total_slots`.
86    pub ell_padding_ratio: f64,
87    /// Total memory consumption of the HYB format in bytes.
88    pub memory_bytes: usize,
89    /// Memory consumption of an equivalent CSR representation in bytes,
90    /// for comparison purposes.
91    pub csr_memory_bytes: usize,
92}
93
94/// Compute the optimal ELL width from a distribution of per-row nnz counts.
95///
96/// The heuristic balances two competing goals:
97/// 1. Minimise ELL padding waste (favours small width).
98/// 2. Minimise COO overflow volume (favours large width).
99///
100/// It selects the width that minimises estimated total storage cost,
101/// modelling ELL cost as `rows * width * (sizeof(i32) + sizeof(T))` and COO
102/// cost as `overflow_nnz * (2 * sizeof(i32) + sizeof(T))`.
103///
104/// Returns 1 if `row_nnz` is empty.
105pub fn optimal_ell_width<T: GpuFloat>(row_nnz: &[usize]) -> usize {
106    if row_nnz.is_empty() {
107        return 1;
108    }
109
110    let rows = row_nnz.len();
111    let max_nnz = row_nnz.iter().copied().max().unwrap_or(1);
112    if max_nnz == 0 {
113        return 1;
114    }
115
116    let ell_entry_bytes = mem::size_of::<i32>() + mem::size_of::<T>();
117    let coo_entry_bytes = 2 * mem::size_of::<i32>() + mem::size_of::<T>();
118
119    let mut best_width = 1;
120    let mut best_cost = usize::MAX;
121
122    // Evaluate each candidate width from 1..=max_nnz
123    for w in 1..=max_nnz {
124        let ell_cost = rows * w * ell_entry_bytes;
125        let overflow: usize = row_nnz.iter().map(|&r| r.saturating_sub(w)).sum();
126        let coo_cost = overflow * coo_entry_bytes;
127        let total = ell_cost + coo_cost;
128        if total < best_cost {
129            best_cost = total;
130            best_width = w;
131        }
132    }
133
134    best_width
135}
136
137/// Compute the ELL width from a per-row nnz distribution and a partition
138/// strategy.
139fn compute_ell_width(row_nnz: &[usize], partition: HybPartition) -> usize {
140    if row_nnz.is_empty() {
141        return 1;
142    }
143    match partition {
144        HybPartition::Auto => {
145            // Median of the per-row nnz distribution
146            let mut sorted = row_nnz.to_vec();
147            sorted.sort_unstable();
148            let mid = sorted.len() / 2;
149            let median = if sorted.len() % 2 == 0 {
150                (sorted[mid.saturating_sub(1)] + sorted[mid]) / 2
151            } else {
152                sorted[mid]
153            };
154            median.max(1)
155        }
156        HybPartition::Max => row_nnz.iter().copied().max().unwrap_or(1).max(1),
157        HybPartition::Threshold(pct) => {
158            let pct = pct.clamp(0.0, 1.0);
159            let mut sorted = row_nnz.to_vec();
160            sorted.sort_unstable();
161            let idx = ((sorted.len() as f64 * pct).ceil() as usize)
162                .min(sorted.len())
163                .saturating_sub(1);
164            sorted[idx].max(1)
165        }
166        HybPartition::Fixed(w) => w.max(1),
167    }
168}
169
170impl<T: GpuFloat> HybMatrix<T> {
171    /// Creates a new HYB matrix from raw host-side arrays.
172    ///
173    /// # Arguments
174    ///
175    /// * `rows` -- Number of rows.
176    /// * `cols` -- Number of columns.
177    /// * `ell_width` -- Maximum entries per row in the ELL portion.
178    /// * `ell_col_indices` -- ELL column indices, length `rows * ell_width`,
179    ///   column-major. Unused entries must be [`HYB_ELL_SENTINEL`].
180    /// * `ell_values` -- ELL values, length `rows * ell_width`, column-major.
181    /// * `coo_row_indices` -- COO overflow row indices.
182    /// * `coo_col_indices` -- COO overflow column indices.
183    /// * `coo_values` -- COO overflow values.
184    ///
185    /// # Errors
186    ///
187    /// Returns [`SparseError::InvalidFormat`] if array lengths are inconsistent.
188    #[allow(clippy::too_many_arguments)]
189    pub fn new(
190        rows: usize,
191        cols: usize,
192        ell_width: usize,
193        ell_col_indices: Vec<i32>,
194        ell_values: Vec<T>,
195        coo_row_indices: Vec<i32>,
196        coo_col_indices: Vec<i32>,
197        coo_values: Vec<T>,
198    ) -> SparseResult<Self> {
199        if rows == 0 || cols == 0 {
200            return Err(SparseError::InvalidFormat(
201                "rows and cols must be non-zero".to_string(),
202            ));
203        }
204        if ell_width == 0 {
205            return Err(SparseError::InvalidFormat(
206                "ell_width must be non-zero".to_string(),
207            ));
208        }
209        let ell_total = rows * ell_width;
210        if ell_col_indices.len() != ell_total {
211            return Err(SparseError::InvalidFormat(format!(
212                "ell_col_indices length ({}) must be rows * ell_width ({})",
213                ell_col_indices.len(),
214                ell_total
215            )));
216        }
217        if ell_values.len() != ell_total {
218            return Err(SparseError::InvalidFormat(format!(
219                "ell_values length ({}) must be rows * ell_width ({})",
220                ell_values.len(),
221                ell_total
222            )));
223        }
224        let coo_nnz = coo_values.len();
225        if coo_row_indices.len() != coo_nnz || coo_col_indices.len() != coo_nnz {
226            return Err(SparseError::InvalidFormat(format!(
227                "COO arrays must have equal length: row_indices={}, col_indices={}, values={}",
228                coo_row_indices.len(),
229                coo_col_indices.len(),
230                coo_nnz
231            )));
232        }
233
234        Ok(Self {
235            rows,
236            cols,
237            ell_width,
238            ell_col_indices,
239            ell_values,
240            coo_nnz,
241            coo_row_indices,
242            coo_col_indices,
243            coo_values,
244        })
245    }
246
247    /// Constructs a HYB matrix from a CSR matrix using the given partition
248    /// strategy.
249    ///
250    /// Downloads the CSR data from GPU, computes per-row nnz, determines
251    /// the ELL width according to `partition`, then fills ELL up to that
252    /// width and overflows the remainder into COO.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`SparseError::Cuda`] on GPU transfer failure.
257    pub fn from_csr(csr: &CsrMatrix<T>, partition: HybPartition) -> SparseResult<Self> {
258        let (h_row_ptr, h_col_idx, h_values) = csr.to_host()?;
259        let rows = csr.rows() as usize;
260        let cols = csr.cols() as usize;
261
262        // Compute per-row nnz
263        let mut row_nnz = Vec::with_capacity(rows);
264        for i in 0..rows {
265            row_nnz.push((h_row_ptr[i + 1] - h_row_ptr[i]) as usize);
266        }
267
268        let ell_width = compute_ell_width(&row_nnz, partition);
269        Self::build_from_csr_host(rows, cols, ell_width, &h_row_ptr, &h_col_idx, &h_values)
270    }
271
272    /// Constructs a HYB matrix from a COO matrix (via CSR intermediate)
273    /// using the given partition strategy.
274    ///
275    /// Converts COO to CSR first, then builds HYB from the CSR data.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`SparseError::Cuda`] on GPU transfer failure.
280    pub fn from_coo(coo: &super::CooMatrix<T>, partition: HybPartition) -> SparseResult<Self> {
281        let csr = coo.to_csr()?;
282        Self::from_csr(&csr, partition)
283    }
284
285    /// Converts this HYB matrix back to CSR format, uploading to GPU.
286    ///
287    /// Merges the ELL and COO portions into a single CSR representation.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`SparseError::Cuda`] on GPU allocation failure.
292    /// Returns [`SparseError::ZeroNnz`] if the matrix has no non-zeros.
293    pub fn to_csr(&self) -> SparseResult<CsrMatrix<T>> {
294        let total_nnz = self.total_nnz();
295        if total_nnz == 0 {
296            return Err(SparseError::ZeroNnz);
297        }
298
299        // Count per-row nnz from both ELL and COO
300        let mut row_counts = vec![0usize; self.rows];
301
302        // ELL contribution
303        for (i, count) in row_counts.iter_mut().enumerate() {
304            for k in 0..self.ell_width {
305                let idx = k * self.rows + i;
306                if self.ell_col_indices[idx] != HYB_ELL_SENTINEL {
307                    *count += 1;
308                }
309            }
310        }
311
312        // COO contribution
313        for &r in &self.coo_row_indices {
314            let row = r as usize;
315            if row < self.rows {
316                row_counts[row] += 1;
317            }
318        }
319
320        // Build row_ptr
321        let mut h_row_ptr = vec![0i32; self.rows + 1];
322        for i in 0..self.rows {
323            h_row_ptr[i + 1] = h_row_ptr[i] + row_counts[i] as i32;
324        }
325
326        // Place entries
327        let mut h_col_idx = vec![0i32; total_nnz];
328        let mut h_values = vec![T::gpu_zero(); total_nnz];
329        let mut write_pos: Vec<i32> = h_row_ptr.clone();
330
331        // ELL entries first
332        for (i, pos) in write_pos.iter_mut().enumerate().take(self.rows) {
333            for k in 0..self.ell_width {
334                let idx = k * self.rows + i;
335                let col = self.ell_col_indices[idx];
336                if col != HYB_ELL_SENTINEL {
337                    let dest = *pos as usize;
338                    h_col_idx[dest] = col;
339                    h_values[dest] = self.ell_values[idx];
340                    *pos += 1;
341                }
342            }
343        }
344
345        // COO entries
346        for j in 0..self.coo_nnz {
347            let row = self.coo_row_indices[j] as usize;
348            if row < self.rows {
349                let dest = write_pos[row] as usize;
350                h_col_idx[dest] = self.coo_col_indices[j];
351                h_values[dest] = self.coo_values[j];
352                write_pos[row] += 1;
353            }
354        }
355
356        CsrMatrix::from_host(
357            self.rows as u32,
358            self.cols as u32,
359            &h_row_ptr,
360            &h_col_idx,
361            &h_values,
362        )
363    }
364
365    /// Returns the total number of non-zeros (ELL + COO).
366    pub fn total_nnz(&self) -> usize {
367        self.ell_nnz() + self.coo_nnz
368    }
369
370    /// Returns the number of non-zeros (alias for [`total_nnz`](Self::total_nnz)).
371    #[inline]
372    pub fn nnz(&self) -> usize {
373        self.total_nnz()
374    }
375
376    /// Returns `true` if the matrix contains no non-zero entries.
377    #[inline]
378    pub fn is_empty(&self) -> bool {
379        self.total_nnz() == 0
380    }
381
382    /// Returns the number of non-zeros stored in the ELL portion.
383    pub fn ell_nnz(&self) -> usize {
384        self.ell_col_indices
385            .iter()
386            .filter(|&&c| c != HYB_ELL_SENTINEL)
387            .count()
388    }
389
390    /// Returns the number of rows.
391    #[inline]
392    pub fn rows(&self) -> usize {
393        self.rows
394    }
395
396    /// Returns the number of columns.
397    #[inline]
398    pub fn cols(&self) -> usize {
399        self.cols
400    }
401
402    /// Returns the ELL width (max entries per row in ELL portion).
403    #[inline]
404    pub fn ell_width(&self) -> usize {
405        self.ell_width
406    }
407
408    /// Returns a reference to the ELL column indices (column-major).
409    #[inline]
410    pub fn ell_col_indices(&self) -> &[i32] {
411        &self.ell_col_indices
412    }
413
414    /// Returns a reference to the ELL values (column-major).
415    #[inline]
416    pub fn ell_values(&self) -> &[T] {
417        &self.ell_values
418    }
419
420    /// Returns the number of overflow entries in the COO portion.
421    #[inline]
422    pub fn coo_nnz(&self) -> usize {
423        self.coo_nnz
424    }
425
426    /// Returns a reference to the COO overflow row indices.
427    #[inline]
428    pub fn coo_row_indices(&self) -> &[i32] {
429        &self.coo_row_indices
430    }
431
432    /// Returns a reference to the COO overflow column indices.
433    #[inline]
434    pub fn coo_col_indices(&self) -> &[i32] {
435        &self.coo_col_indices
436    }
437
438    /// Returns a reference to the COO overflow values.
439    #[inline]
440    pub fn coo_values(&self) -> &[T] {
441        &self.coo_values
442    }
443
444    /// Computes statistics describing the quality of the HYB partition.
445    ///
446    /// The returned [`HybStatistics`] includes the fraction of nnz in each
447    /// portion, the ELL padding ratio, and memory consumption compared to
448    /// an equivalent CSR matrix.
449    pub fn statistics(&self) -> HybStatistics {
450        let ell_nnz = self.ell_nnz();
451        let total_nnz = ell_nnz + self.coo_nnz;
452
453        let (ell_fraction, coo_fraction) = if total_nnz == 0 {
454            (0.0, 0.0)
455        } else {
456            (
457                ell_nnz as f64 / total_nnz as f64,
458                self.coo_nnz as f64 / total_nnz as f64,
459            )
460        };
461
462        let ell_total_slots = self.rows * self.ell_width;
463        let ell_padding_ratio = if ell_total_slots == 0 {
464            0.0
465        } else {
466            (ell_total_slots - ell_nnz) as f64 / ell_total_slots as f64
467        };
468
469        // HYB memory: ELL (col_indices + values) + COO (row + col + values)
470        let ell_mem = ell_total_slots * (mem::size_of::<i32>() + mem::size_of::<T>());
471        let coo_mem = self.coo_nnz * (2 * mem::size_of::<i32>() + mem::size_of::<T>());
472        let memory_bytes = ell_mem + coo_mem;
473
474        // CSR memory: row_ptr + col_idx + values
475        let csr_memory_bytes = (self.rows + 1) * mem::size_of::<i32>()
476            + total_nnz * mem::size_of::<i32>()
477            + total_nnz * mem::size_of::<T>();
478
479        HybStatistics {
480            ell_fraction,
481            coo_fraction,
482            ell_padding_ratio,
483            memory_bytes,
484            csr_memory_bytes,
485        }
486    }
487
488    /// Internal helper: build HYB from host-side CSR arrays.
489    fn build_from_csr_host(
490        rows: usize,
491        cols: usize,
492        ell_width: usize,
493        h_row_ptr: &[i32],
494        h_col_idx: &[i32],
495        h_values: &[T],
496    ) -> SparseResult<Self> {
497        let ell_total = rows * ell_width;
498        let mut ell_col_indices = vec![HYB_ELL_SENTINEL; ell_total];
499        let mut ell_values = vec![T::gpu_zero(); ell_total];
500
501        let mut coo_row_indices = Vec::new();
502        let mut coo_col_indices = Vec::new();
503        let mut coo_values = Vec::new();
504
505        for i in 0..rows {
506            let start = h_row_ptr[i] as usize;
507            let end = h_row_ptr[i + 1] as usize;
508            let row_entries = end - start;
509
510            // Fill ELL portion (up to ell_width entries)
511            let ell_count = row_entries.min(ell_width);
512            for k in 0..ell_count {
513                let ell_idx = k * rows + i; // column-major
514                ell_col_indices[ell_idx] = h_col_idx[start + k];
515                ell_values[ell_idx] = h_values[start + k];
516            }
517
518            // Overflow goes to COO
519            if row_entries > ell_width {
520                for j in (start + ell_width)..end {
521                    coo_row_indices.push(i as i32);
522                    coo_col_indices.push(h_col_idx[j]);
523                    coo_values.push(h_values[j]);
524                }
525            }
526        }
527
528        let coo_nnz = coo_values.len();
529
530        Ok(Self {
531            rows,
532            cols,
533            ell_width,
534            ell_col_indices,
535            ell_values,
536            coo_nnz,
537            coo_row_indices,
538            coo_col_indices,
539            coo_values,
540        })
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    // Helper: build a HYB matrix from host CSR arrays directly (no GPU).
549    fn hyb_from_csr_host(
550        rows: usize,
551        cols: usize,
552        row_ptr: &[i32],
553        col_idx: &[i32],
554        values: &[f64],
555        partition: HybPartition,
556    ) -> SparseResult<HybMatrix<f64>> {
557        let mut row_nnz = Vec::with_capacity(rows);
558        for i in 0..rows {
559            row_nnz.push((row_ptr[i + 1] - row_ptr[i]) as usize);
560        }
561        let ell_width = compute_ell_width(&row_nnz, partition);
562        HybMatrix::build_from_csr_host(rows, cols, ell_width, row_ptr, col_idx, values)
563    }
564
565    /// 4x4 identity-like test matrix:
566    /// Row 0: (0,0)=1.0
567    /// Row 1: (1,1)=2.0
568    /// Row 2: (2,0)=3.0, (2,2)=4.0
569    /// Row 3: (3,0)=5.0, (3,1)=6.0, (3,3)=7.0
570    fn test_csr_data() -> (usize, usize, Vec<i32>, Vec<i32>, Vec<f64>) {
571        let rows = 4;
572        let cols = 4;
573        let row_ptr = vec![0, 1, 2, 4, 7];
574        let col_idx = vec![0, 1, 0, 2, 0, 1, 3];
575        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
576        (rows, cols, row_ptr, col_idx, values)
577    }
578
579    #[test]
580    fn hyb_from_csr_auto_partition() {
581        let (rows, cols, row_ptr, col_idx, values) = test_csr_data();
582        // Row nnz: [1, 1, 2, 3] => median = (1+2)/2 = 1 (integer division)
583        // Actually median of sorted [1,1,2,3]: even len => (sorted[1]+sorted[2])/2 = (1+2)/2 = 1
584        let hyb = hyb_from_csr_host(rows, cols, &row_ptr, &col_idx, &values, HybPartition::Auto);
585        assert!(hyb.is_ok());
586        let hyb = hyb.expect("test helper");
587        assert_eq!(hyb.rows(), 4);
588        assert_eq!(hyb.cols(), 4);
589        assert_eq!(hyb.ell_width(), 1);
590        // ELL stores first entry per row: 4 entries
591        assert_eq!(hyb.ell_nnz(), 4);
592        // COO stores overflow: row 2 has 1 extra, row 3 has 2 extra => 3
593        assert_eq!(hyb.coo_nnz(), 3);
594        assert_eq!(hyb.total_nnz(), 7);
595    }
596
597    #[test]
598    fn hyb_from_csr_max_partition() {
599        let (rows, cols, row_ptr, col_idx, values) = test_csr_data();
600        // Max nnz = 3, so pure ELL, no COO
601        let hyb = hyb_from_csr_host(rows, cols, &row_ptr, &col_idx, &values, HybPartition::Max);
602        let hyb = hyb.expect("test helper");
603        assert_eq!(hyb.ell_width(), 3);
604        assert_eq!(hyb.coo_nnz(), 0);
605        assert_eq!(hyb.total_nnz(), 7);
606    }
607
608    #[test]
609    fn hyb_from_csr_fixed_partition() {
610        let (rows, cols, row_ptr, col_idx, values) = test_csr_data();
611        let hyb = hyb_from_csr_host(
612            rows,
613            cols,
614            &row_ptr,
615            &col_idx,
616            &values,
617            HybPartition::Fixed(2),
618        );
619        let hyb = hyb.expect("test helper");
620        assert_eq!(hyb.ell_width(), 2);
621        // Rows: 1,1,2,3 => ELL stores min(nnz,2) each: 1+1+2+2 = 6
622        assert_eq!(hyb.ell_nnz(), 6);
623        // COO: row 3 has 1 overflow
624        assert_eq!(hyb.coo_nnz(), 1);
625        assert_eq!(hyb.total_nnz(), 7);
626    }
627
628    #[test]
629    fn hyb_from_csr_threshold_partition() {
630        let (rows, cols, row_ptr, col_idx, values) = test_csr_data();
631        // Sorted row nnz: [1, 1, 2, 3], 90th percentile => index ceil(4*0.9)-1=3 => 3
632        let hyb = hyb_from_csr_host(
633            rows,
634            cols,
635            &row_ptr,
636            &col_idx,
637            &values,
638            HybPartition::Threshold(0.9),
639        );
640        let hyb = hyb.expect("test helper");
641        assert_eq!(hyb.ell_width(), 3);
642        assert_eq!(hyb.coo_nnz(), 0);
643    }
644
645    #[test]
646    fn hyb_to_csr_roundtrip() {
647        let (rows, cols, row_ptr, col_idx, values) = test_csr_data();
648        let hyb = hyb_from_csr_host(
649            rows,
650            cols,
651            &row_ptr,
652            &col_idx,
653            &values,
654            HybPartition::Fixed(2),
655        );
656        let hyb = hyb.expect("test helper");
657
658        // to_csr requires GPU; test the internal logic by reconstructing manually
659        // We verify that total_nnz is preserved and data is consistent
660        assert_eq!(hyb.total_nnz(), 7);
661
662        // Verify ELL col-major layout: element (row, k) at k*rows+row
663        // Row 0, k=0: idx=0*4+0=0 => col 0
664        assert_eq!(hyb.ell_col_indices()[0], 0);
665        // Row 1, k=0: idx=0*4+1=1 => col 1
666        assert_eq!(hyb.ell_col_indices()[1], 1);
667        // Row 2, k=0: idx=0*4+2=2 => col 0
668        assert_eq!(hyb.ell_col_indices()[2], 0);
669        // Row 3, k=0: idx=0*4+3=3 => col 0
670        assert_eq!(hyb.ell_col_indices()[3], 0);
671
672        // Row 2, k=1: idx=1*4+2=6 => col 2
673        assert_eq!(hyb.ell_col_indices()[6], 2);
674        // Row 3, k=1: idx=1*4+3=7 => col 1
675        assert_eq!(hyb.ell_col_indices()[7], 1);
676
677        // COO overflow: row 3, entry (3,3)=7.0
678        assert_eq!(hyb.coo_row_indices(), &[3]);
679        assert_eq!(hyb.coo_col_indices(), &[3]);
680        assert!((hyb.coo_values()[0] - 7.0).abs() < 1e-12);
681    }
682
683    #[test]
684    fn hyb_is_empty_all_padding() {
685        // A matrix where all ELL entries are sentinel (but that's artificial)
686        let ell_col = vec![HYB_ELL_SENTINEL; 4];
687        let ell_val = vec![0.0f64; 4];
688        let hyb = HybMatrix::new(2, 2, 2, ell_col, ell_val, vec![], vec![], vec![]);
689        let hyb = hyb.expect("test helper");
690        assert!(hyb.is_empty());
691        assert_eq!(hyb.nnz(), 0);
692    }
693
694    #[test]
695    fn hyb_statistics_pure_ell() {
696        let (rows, cols, row_ptr, col_idx, values) = test_csr_data();
697        let hyb = hyb_from_csr_host(rows, cols, &row_ptr, &col_idx, &values, HybPartition::Max);
698        let hyb = hyb.expect("test helper");
699        let stats = hyb.statistics();
700
701        assert!((stats.ell_fraction - 1.0).abs() < 1e-12);
702        assert!((stats.coo_fraction - 0.0).abs() < 1e-12);
703        // ELL total slots = 4*3 = 12, nnz = 7, padding = 5/12
704        assert!((stats.ell_padding_ratio - 5.0 / 12.0).abs() < 1e-12);
705        assert!(stats.memory_bytes > 0);
706        assert!(stats.csr_memory_bytes > 0);
707    }
708
709    #[test]
710    fn hyb_statistics_mixed() {
711        let (rows, cols, row_ptr, col_idx, values) = test_csr_data();
712        let hyb = hyb_from_csr_host(
713            rows,
714            cols,
715            &row_ptr,
716            &col_idx,
717            &values,
718            HybPartition::Fixed(1),
719        );
720        let hyb = hyb.expect("test helper");
721        let stats = hyb.statistics();
722
723        // ELL nnz = 4 (one per row), COO nnz = 3
724        assert!((stats.ell_fraction - 4.0 / 7.0).abs() < 1e-12);
725        assert!((stats.coo_fraction - 3.0 / 7.0).abs() < 1e-12);
726        // ELL padding = 0/4 = 0 (all slots used)
727        assert!((stats.ell_padding_ratio - 0.0).abs() < 1e-12);
728    }
729
730    #[test]
731    fn hyb_new_validation_bad_ell_lengths() {
732        let result = HybMatrix::<f64>::new(
733            2,
734            2,
735            2,
736            vec![0; 3], // wrong length, should be 4
737            vec![1.0; 4],
738            vec![],
739            vec![],
740            vec![],
741        );
742        assert!(result.is_err());
743    }
744
745    #[test]
746    fn hyb_new_validation_bad_coo_lengths() {
747        let result = HybMatrix::<f64>::new(
748            2,
749            2,
750            1,
751            vec![HYB_ELL_SENTINEL; 2],
752            vec![0.0; 2],
753            vec![0],    // 1 entry
754            vec![0, 1], // 2 entries -- mismatch
755            vec![1.0],  // 1 entry
756        );
757        assert!(result.is_err());
758    }
759
760    #[test]
761    fn hyb_new_validation_zero_rows() {
762        let result = HybMatrix::<f64>::new(0, 2, 1, vec![], vec![], vec![], vec![], vec![]);
763        assert!(result.is_err());
764    }
765
766    #[test]
767    fn hyb_new_validation_zero_ell_width() {
768        let result = HybMatrix::<f64>::new(2, 2, 0, vec![], vec![], vec![], vec![], vec![]);
769        assert!(result.is_err());
770    }
771
772    #[test]
773    fn hyb_ell_values_column_major_layout() {
774        // 3x3 matrix:
775        // Row 0: (0,0)=1, (0,1)=2
776        // Row 1: (1,2)=3
777        // Row 2: (2,0)=4, (2,1)=5, (2,2)=6
778        let row_ptr = vec![0, 2, 3, 6];
779        let col_idx = vec![0, 1, 2, 0, 1, 2];
780        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
781
782        let hyb = hyb_from_csr_host(3, 3, &row_ptr, &col_idx, &values, HybPartition::Fixed(2));
783        let hyb = hyb.expect("test helper");
784
785        // ELL width=2, rows=3
786        // (0,0): k=0, row=0 => idx=0*3+0=0 => col 0, val 1.0
787        assert_eq!(hyb.ell_col_indices()[0], 0);
788        assert!((hyb.ell_values()[0] - 1.0).abs() < 1e-12);
789
790        // (1,0): k=0, row=1 => idx=0*3+1=1 => col 2, val 3.0
791        assert_eq!(hyb.ell_col_indices()[1], 2);
792        assert!((hyb.ell_values()[1] - 3.0).abs() < 1e-12);
793
794        // (2,0): k=0, row=2 => idx=0*3+2=2 => col 0, val 4.0
795        assert_eq!(hyb.ell_col_indices()[2], 0);
796
797        // (0,1): k=1, row=0 => idx=1*3+0=3 => col 1, val 2.0
798        assert_eq!(hyb.ell_col_indices()[3], 1);
799        assert!((hyb.ell_values()[3] - 2.0).abs() < 1e-12);
800
801        // (1,1): k=1, row=1 => idx=1*3+1=4 => sentinel (row 1 only has 1 entry)
802        assert_eq!(hyb.ell_col_indices()[4], HYB_ELL_SENTINEL);
803
804        // (2,1): k=1, row=2 => idx=1*3+2=5 => col 1, val 5.0
805        assert_eq!(hyb.ell_col_indices()[5], 1);
806
807        // COO overflow: row 2 has 1 overflow entry (2,2)=6.0
808        assert_eq!(hyb.coo_nnz(), 1);
809        assert_eq!(hyb.coo_row_indices(), &[2]);
810        assert_eq!(hyb.coo_col_indices(), &[2]);
811        assert!((hyb.coo_values()[0] - 6.0).abs() < 1e-12);
812    }
813
814    #[test]
815    fn optimal_ell_width_basic() {
816        // All rows have 2 nnz => optimal width is 2
817        let row_nnz = vec![2, 2, 2, 2];
818        let w = optimal_ell_width::<f64>(&row_nnz);
819        assert_eq!(w, 2);
820    }
821
822    #[test]
823    fn optimal_ell_width_skewed() {
824        // Most rows have 1 nnz, one outlier with 100
825        let mut row_nnz = vec![1; 99];
826        row_nnz.push(100);
827        let w = optimal_ell_width::<f64>(&row_nnz);
828        // Should pick a small width since padding 99 rows to 100 is wasteful
829        assert!(w < 10, "expected small ELL width, got {w}");
830    }
831
832    #[test]
833    fn optimal_ell_width_empty() {
834        let w = optimal_ell_width::<f64>(&[]);
835        assert_eq!(w, 1);
836    }
837
838    #[test]
839    fn optimal_ell_width_all_zero() {
840        let w = optimal_ell_width::<f64>(&[0, 0, 0]);
841        assert_eq!(w, 1);
842    }
843
844    #[test]
845    fn hyb_partition_threshold_boundary() {
846        // Threshold(1.0) should behave like Max
847        let (rows, cols, row_ptr, col_idx, values) = test_csr_data();
848        let hyb = hyb_from_csr_host(
849            rows,
850            cols,
851            &row_ptr,
852            &col_idx,
853            &values,
854            HybPartition::Threshold(1.0),
855        );
856        let hyb = hyb.expect("test helper");
857        assert_eq!(hyb.ell_width(), 3);
858        assert_eq!(hyb.coo_nnz(), 0);
859    }
860
861    #[test]
862    fn hyb_partition_threshold_zero() {
863        // Threshold(0.0) should give width = 1 (minimum nnz)
864        let (rows, cols, row_ptr, col_idx, values) = test_csr_data();
865        let hyb = hyb_from_csr_host(
866            rows,
867            cols,
868            &row_ptr,
869            &col_idx,
870            &values,
871            HybPartition::Threshold(0.0),
872        );
873        let hyb = hyb.expect("test helper");
874        assert_eq!(hyb.ell_width(), 1);
875    }
876
877    #[test]
878    fn hyb_statistics_memory_comparison() {
879        // For a well-partitioned matrix, HYB memory should be reasonable
880        let (rows, cols, row_ptr, col_idx, values) = test_csr_data();
881        let hyb = hyb_from_csr_host(
882            rows,
883            cols,
884            &row_ptr,
885            &col_idx,
886            &values,
887            HybPartition::Fixed(2),
888        );
889        let hyb = hyb.expect("test helper");
890        let stats = hyb.statistics();
891
892        // ELL: 4*2*(4+8) = 96 bytes
893        // COO: 1*(8+8) = 20 bytes (2*4 + 8)
894        // Total: 116 bytes
895        let expected_ell = 4 * 2 * (std::mem::size_of::<i32>() + std::mem::size_of::<f64>());
896        let expected_coo = 2 * std::mem::size_of::<i32>() + std::mem::size_of::<f64>();
897        assert_eq!(stats.memory_bytes, expected_ell + expected_coo);
898
899        // CSR: row_ptr(5*4) + col_idx(7*4) + values(7*8) = 20+28+56 = 104
900        let expected_csr = 5 * std::mem::size_of::<i32>()
901            + 7 * std::mem::size_of::<i32>()
902            + 7 * std::mem::size_of::<f64>();
903        assert_eq!(stats.csr_memory_bytes, expected_csr);
904    }
905
906    // --- New targeted tests ---
907
908    #[test]
909    fn hyb_identity_4x4_no_coo_overflow() {
910        // 4x4 identity: each row has exactly 1 nnz
911        // row_ptr = [0,1,2,3,4], col_idx = [0,1,2,3], values = [1,1,1,1]
912        // Auto uses median([1,1,1,1]) = 1 => ell_width = 1, zero COO overflow
913        let rows = 4usize;
914        let cols = 4usize;
915        let row_ptr = vec![0i32, 1, 2, 3, 4];
916        let col_idx = vec![0i32, 1, 2, 3];
917        let values = vec![1.0f64; 4];
918        let hyb = hyb_from_csr_host(rows, cols, &row_ptr, &col_idx, &values, HybPartition::Auto)
919            .expect("identity 4x4 hyb construction");
920        assert_eq!(hyb.ell_width(), 1, "ell_width should be 1 for identity");
921        assert_eq!(
922            hyb.coo_nnz(),
923            0,
924            "no COO overflow for uniform-density matrix"
925        );
926        assert_eq!(hyb.total_nnz(), 4);
927    }
928
929    #[test]
930    fn hyb_irregular_matrix_has_coo_entries() {
931        // 4x4 matrix: row 0 has 5 entries (but max col = 3, so wrap), others have 1
932        // Use 5 columns to allow 5 entries in row 0
933        // row 0: cols 0,1,2,3,4 (5 entries)
934        // row 1: col 0 (1 entry)
935        // row 2: col 1 (1 entry)
936        // row 3: col 2 (1 entry)
937        // nnz: [5,1,1,1], median of sorted [1,1,1,5] => (1+1)/2 = 1 => ell_width=1
938        // COO overflow: row 0 has 4 extra entries
939        let rows = 4usize;
940        let cols = 5usize;
941        let row_ptr = vec![0i32, 5, 6, 7, 8];
942        let col_idx = vec![0i32, 1, 2, 3, 4, 0, 1, 2];
943        let values = vec![1.0f64, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
944        let hyb = hyb_from_csr_host(rows, cols, &row_ptr, &col_idx, &values, HybPartition::Auto)
945            .expect("irregular hyb construction");
946        assert_eq!(hyb.ell_width(), 1);
947        assert_eq!(
948            hyb.coo_nnz(),
949            4,
950            "row 0 overflows 4 entries into COO (5 nnz with ell_width=1)"
951        );
952    }
953
954    #[test]
955    fn hyb_spmv_matches_csr() {
956        // 4x4 banded matrix: row i has entries at col i and col (i+1)%4
957        // Use Fixed(1) => ell_width=1, COO gets the second entry per row
958        // Verify y = A*x for x=[1,2,3,4] matches naive CSR multiply
959        let rows = 4usize;
960        let cols = 4usize;
961        // Each row i: entries at col i (value=2.0) and col (i+1)%4 (value=1.0)
962        let row_ptr = vec![0i32, 2, 4, 6, 8];
963        let col_idx = vec![0i32, 1, 1, 2, 2, 3, 3, 0];
964        let values = vec![2.0f64, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0];
965        let x = [1.0f64, 2.0, 3.0, 4.0];
966
967        // Compute naive CSR SpMV
968        let mut y_csr = vec![0.0f64; rows];
969        for i in 0..rows {
970            let start = row_ptr[i] as usize;
971            let end = row_ptr[i + 1] as usize;
972            for j in start..end {
973                y_csr[i] += values[j] * x[col_idx[j] as usize];
974            }
975        }
976
977        // Build HYB with ell_width=1 (COO gets the second column per row)
978        let hyb = hyb_from_csr_host(
979            rows,
980            cols,
981            &row_ptr,
982            &col_idx,
983            &values,
984            HybPartition::Fixed(1),
985        )
986        .expect("banded hyb construction");
987        assert_eq!(hyb.coo_nnz(), 4, "each row overflows 1 entry to COO");
988
989        // Compute HYB SpMV manually:
990        // ELL part (column-major): slot k of row i is at index k*rows+i
991        let mut y_hyb = vec![0.0f64; rows];
992        for k in 0..hyb.ell_width() {
993            for (i, y_val) in y_hyb.iter_mut().enumerate() {
994                let idx = k * rows + i;
995                let c = hyb.ell_col_indices()[idx];
996                if c >= 0 {
997                    *y_val += hyb.ell_values()[idx] * x[c as usize];
998                }
999            }
1000        }
1001        // COO part
1002        for idx in 0..hyb.coo_nnz() {
1003            let r = hyb.coo_row_indices()[idx] as usize;
1004            let c = hyb.coo_col_indices()[idx] as usize;
1005            y_hyb[r] += hyb.coo_values()[idx] * x[c];
1006        }
1007
1008        for i in 0..rows {
1009            assert!(
1010                (y_hyb[i] - y_csr[i]).abs() < 1e-10,
1011                "HYB SpMV mismatch at row {}: hyb={}, csr={}",
1012                i,
1013                y_hyb[i],
1014                y_csr[i]
1015            );
1016        }
1017    }
1018
1019    #[test]
1020    fn hyb_ell_width_is_avg_nnz() {
1021        // 4x4 matrix with 8 total nnz: each row has exactly 2 nnz
1022        // Use Fixed(2) to explicitly set ell_width=2 (avg = 8/4 = 2)
1023        let rows = 4usize;
1024        let cols = 4usize;
1025        let row_ptr = vec![0i32, 2, 4, 6, 8];
1026        let col_idx = vec![0i32, 1, 1, 2, 2, 3, 3, 0];
1027        let values = vec![1.0f64; 8];
1028        let hyb = hyb_from_csr_host(
1029            rows,
1030            cols,
1031            &row_ptr,
1032            &col_idx,
1033            &values,
1034            HybPartition::Fixed(2),
1035        )
1036        .expect("uniform 2-per-row hyb construction");
1037        assert_eq!(
1038            hyb.ell_width(),
1039            2,
1040            "ell_width should be 2 (= avg nnz per row)"
1041        );
1042        assert_eq!(
1043            hyb.coo_nnz(),
1044            0,
1045            "no overflow when ell_width == max nnz per row"
1046        );
1047    }
1048
1049    #[test]
1050    fn hyb_coo_stores_overflow() {
1051        // Matrix where row 0 has 3 entries, rows 1-3 have 1 entry each
1052        // ell_width = Fixed(1) => row 0 overflows 2 entries to COO
1053        // Verify coo_row_indices contains [0, 0] for both overflow entries
1054        let rows = 4usize;
1055        let cols = 4usize;
1056        let row_ptr = vec![0i32, 3, 4, 5, 6];
1057        let col_idx = vec![0i32, 1, 2, 1, 2, 3];
1058        let values = vec![10.0f64, 20.0, 30.0, 40.0, 50.0, 60.0];
1059        let hyb = hyb_from_csr_host(
1060            rows,
1061            cols,
1062            &row_ptr,
1063            &col_idx,
1064            &values,
1065            HybPartition::Fixed(1),
1066        )
1067        .expect("overflow test hyb construction");
1068        assert_eq!(hyb.coo_nnz(), 2, "row 0 overflows 2 entries to COO");
1069        // Both COO entries must belong to row 0
1070        for &r in hyb.coo_row_indices() {
1071            assert_eq!(r, 0i32, "all COO overflow entries should be from row 0");
1072        }
1073    }
1074}