Skip to main content

oxicuda_sparse/ops/
matrix_powers.rs

1//! Sparse matrix powers and polynomial evaluation.
2//!
3//! Computes `A^k` via repeated SpGEMM with binary exponentiation and optional
4//! structure reuse optimization. Also provides matrix polynomial evaluation
5//! via Horner's method: `p(A) = c0*I + c1*A + c2*A^2 + ... + ck*A^k`.
6//!
7//! All operations work on CSR (Compressed Sparse Row) format stored as
8//! `(row_offsets, col_indices, values)` triple on the host side.
9//!
10//! (C) 2026 COOLJAPAN OU (Team KitaSan)
11
12use std::collections::HashMap;
13
14use crate::error::SparseError;
15
16/// A CSR matrix stored as `(row_offsets, col_indices, values)`.
17type CsrTriple = (Vec<usize>, Vec<usize>, Vec<f64>);
18
19// ---------------------------------------------------------------------------
20// Configuration & result types
21// ---------------------------------------------------------------------------
22
23/// Configuration for sparse matrix power computation.
24#[derive(Debug, Clone)]
25pub struct MatrixPowerConfig {
26    /// Maximum allowed nnz in any intermediate or final result.
27    /// If exceeded, the computation aborts with an error.
28    pub max_nnz: Option<usize>,
29    /// Whether to attempt reusing symbolic (sparsity) structure between
30    /// successive multiplications when the structure matches.
31    pub reuse_structure: bool,
32    /// The exponent `k` in `A^k`.
33    pub power: usize,
34}
35
36/// Result of a sparse matrix power computation.
37#[derive(Debug, Clone)]
38pub struct MatrixPowerResult {
39    /// CSR row offsets of the result matrix (length = rows + 1).
40    pub row_offsets: Vec<usize>,
41    /// CSR column indices of the result matrix.
42    pub col_indices: Vec<usize>,
43    /// CSR values of the result matrix.
44    pub values: Vec<f64>,
45    /// Number of rows.
46    pub rows: usize,
47    /// Number of columns.
48    pub cols: usize,
49    /// Number of non-zeros in the result.
50    pub nnz: usize,
51    /// Number of SpGEMM multiplications actually performed.
52    pub multiplications_performed: usize,
53    /// nnz observed after each multiplication step.
54    pub nnz_growth: Vec<usize>,
55}
56
57// ---------------------------------------------------------------------------
58// Public API
59// ---------------------------------------------------------------------------
60
61/// Compute `A^power` for a sparse CSR matrix using binary exponentiation.
62///
63/// Binary exponentiation decomposes the exponent into powers of two so that
64/// `A^8 = ((A^2)^2)^2` requires only 3 multiplications instead of 7.
65///
66/// # Arguments
67///
68/// * `row_offsets` -- CSR row pointer array (length `rows + 1`).
69/// * `col_indices` -- CSR column indices (length `nnz`).
70/// * `values` -- CSR values (length `nnz`).
71/// * `rows`, `cols` -- Matrix dimensions (must be square for power > 1).
72/// * `power` -- The exponent `k`.
73/// * `config` -- [`MatrixPowerConfig`] controlling nnz limits and structure reuse.
74///
75/// # Errors
76///
77/// Returns [`SparseError::DimensionMismatch`] if the matrix is not square
78/// (when `power > 1`), [`SparseError::InvalidFormat`] if CSR arrays are
79/// inconsistent, or [`SparseError::InvalidArgument`] if nnz exceeds `max_nnz`.
80pub fn sparse_matrix_power(
81    row_offsets: &[usize],
82    col_indices: &[usize],
83    values: &[f64],
84    rows: usize,
85    cols: usize,
86    power: usize,
87    config: &MatrixPowerConfig,
88) -> Result<MatrixPowerResult, SparseError> {
89    validate_csr(row_offsets, col_indices, values, rows)?;
90
91    if power > 1 && rows != cols {
92        return Err(SparseError::DimensionMismatch(format!(
93            "matrix must be square for power > 1, got {}x{}",
94            rows, cols
95        )));
96    }
97
98    // power == 0 => identity
99    if power == 0 {
100        let (id_offsets, id_indices, id_values) = sparse_identity(rows);
101        return Ok(MatrixPowerResult {
102            nnz: id_indices.len(),
103            row_offsets: id_offsets,
104            col_indices: id_indices,
105            values: id_values,
106            rows,
107            cols: rows,
108            multiplications_performed: 0,
109            nnz_growth: vec![],
110        });
111    }
112
113    // power == 1 => copy of A
114    if power == 1 {
115        return Ok(MatrixPowerResult {
116            row_offsets: row_offsets.to_vec(),
117            col_indices: col_indices.to_vec(),
118            values: values.to_vec(),
119            rows,
120            cols,
121            nnz: col_indices.len(),
122            multiplications_performed: 0,
123            nnz_growth: vec![col_indices.len()],
124        });
125    }
126
127    // Binary exponentiation
128    // We maintain `base` (current squaring chain) and `result` (accumulated product).
129    let mut base_offsets = row_offsets.to_vec();
130    let mut base_indices = col_indices.to_vec();
131    let mut base_values = values.to_vec();
132
133    let mut result_offsets: Option<Vec<usize>> = None;
134    let mut result_indices: Option<Vec<usize>> = None;
135    let mut result_values: Option<Vec<f64>> = None;
136
137    let mut mults = 0usize;
138    let mut nnz_growth = vec![col_indices.len()];
139    let mut exp = power;
140
141    // Track previous structure for reuse optimization
142    let mut prev_structure: Option<(Vec<usize>, Vec<usize>)> = None;
143
144    while exp > 0 {
145        if exp & 1 == 1 {
146            // result = result * base  (or result = base if first time)
147            if let (Some(r_off), Some(r_idx), Some(r_val)) =
148                (&result_offsets, &result_indices, &result_values)
149            {
150                let (new_off, new_idx, new_val) = host_spgemm(
151                    r_off,
152                    r_idx,
153                    r_val,
154                    rows,
155                    rows,
156                    &base_offsets,
157                    &base_indices,
158                    &base_values,
159                    rows,
160                    rows,
161                )?;
162                mults += 1;
163
164                check_max_nnz(new_idx.len(), config.max_nnz)?;
165                nnz_growth.push(new_idx.len());
166
167                if config.reuse_structure {
168                    if let Some((ref ps_off, ref ps_idx)) = prev_structure {
169                        if ps_off == &new_off && ps_idx == &new_idx {
170                            // Structure matches -- values already computed, nothing extra to do
171                        }
172                    }
173                    prev_structure = Some((new_off.clone(), new_idx.clone()));
174                }
175
176                result_offsets = Some(new_off);
177                result_indices = Some(new_idx);
178                result_values = Some(new_val);
179            } else {
180                result_offsets = Some(base_offsets.clone());
181                result_indices = Some(base_indices.clone());
182                result_values = Some(base_values.clone());
183            }
184        }
185        exp >>= 1;
186        if exp > 0 {
187            // base = base * base
188            let (new_off, new_idx, new_val) = host_spgemm(
189                &base_offsets,
190                &base_indices,
191                &base_values,
192                rows,
193                rows,
194                &base_offsets.clone(),
195                &base_indices.clone(),
196                &base_values.clone(),
197                rows,
198                rows,
199            )?;
200            mults += 1;
201
202            check_max_nnz(new_idx.len(), config.max_nnz)?;
203            nnz_growth.push(new_idx.len());
204
205            if config.reuse_structure {
206                prev_structure = Some((new_off.clone(), new_idx.clone()));
207            }
208
209            base_offsets = new_off;
210            base_indices = new_idx;
211            base_values = new_val;
212        }
213    }
214
215    let r_offsets = result_offsets
216        .ok_or_else(|| SparseError::InternalError("result not computed".to_string()))?;
217    let r_indices = result_indices
218        .ok_or_else(|| SparseError::InternalError("result not computed".to_string()))?;
219    let r_values = result_values
220        .ok_or_else(|| SparseError::InternalError("result not computed".to_string()))?;
221
222    Ok(MatrixPowerResult {
223        nnz: r_indices.len(),
224        row_offsets: r_offsets,
225        col_indices: r_indices,
226        values: r_values,
227        rows,
228        cols: rows,
229        multiplications_performed: mults,
230        nnz_growth,
231    })
232}
233
234/// Host-side sparse matrix-matrix multiplication: `C = A * B`.
235///
236/// Uses a row-by-row accumulator approach with [`HashMap`] for gathering
237/// unique column contributions.
238///
239/// # Arguments
240///
241/// * CSR arrays and dimensions for matrices A and B.
242///
243/// # Returns
244///
245/// CSR triple `(row_offsets, col_indices, values)` for C.
246///
247/// # Errors
248///
249/// Returns [`SparseError::DimensionMismatch`] if `a_cols != b_rows`.
250#[allow(clippy::too_many_arguments)]
251pub fn host_spgemm(
252    a_row_offsets: &[usize],
253    a_col_indices: &[usize],
254    a_values: &[f64],
255    a_rows: usize,
256    a_cols: usize,
257    b_row_offsets: &[usize],
258    b_col_indices: &[usize],
259    b_values: &[f64],
260    b_rows: usize,
261    b_cols: usize,
262) -> Result<CsrTriple, SparseError> {
263    if a_cols != b_rows {
264        return Err(SparseError::DimensionMismatch(format!(
265            "A.cols ({}) != B.rows ({}) in SpGEMM",
266            a_cols, b_rows
267        )));
268    }
269
270    let _ = b_cols; // used for documentation clarity, not directly needed
271
272    let mut c_row_offsets = Vec::with_capacity(a_rows + 1);
273    let mut c_col_indices = Vec::new();
274    let mut c_values = Vec::new();
275
276    c_row_offsets.push(0usize);
277
278    let mut accum: HashMap<usize, f64> = HashMap::new();
279
280    for i in 0..a_rows {
281        accum.clear();
282        let a_start = a_row_offsets[i];
283        let a_end = a_row_offsets[i + 1];
284
285        for idx in a_start..a_end {
286            let j = a_col_indices[idx];
287            let a_ij = a_values[idx];
288
289            let b_start = b_row_offsets[j];
290            let b_end = b_row_offsets[j + 1];
291
292            for b_idx in b_start..b_end {
293                let k = b_col_indices[b_idx];
294                let b_jk = b_values[b_idx];
295                *accum.entry(k).or_insert(0.0) += a_ij * b_jk;
296            }
297        }
298
299        // Sort by column index for canonical CSR
300        let mut entries: Vec<(usize, f64)> = accum.drain().collect();
301        entries.sort_unstable_by_key(|&(col, _)| col);
302
303        for (col, val) in entries {
304            c_col_indices.push(col);
305            c_values.push(val);
306        }
307
308        c_row_offsets.push(c_col_indices.len());
309    }
310
311    Ok((c_row_offsets, c_col_indices, c_values))
312}
313
314/// Construct the CSR representation of an `n x n` identity matrix.
315///
316/// Returns `(row_offsets, col_indices, values)`.
317pub fn sparse_identity(n: usize) -> (Vec<usize>, Vec<usize>, Vec<f64>) {
318    let row_offsets: Vec<usize> = (0..=n).collect();
319    let col_indices: Vec<usize> = (0..n).collect();
320    let values = vec![1.0; n];
321    (row_offsets, col_indices, values)
322}
323
324/// Heuristic estimate of `nnz(A^k)` based on average degree.
325///
326/// Uses `avg_degree = nnz / rows` and estimates the result as
327/// `min(rows * min(rows, avg_degree^k), rows * rows)`.
328pub fn estimate_power_nnz(
329    row_offsets: &[usize],
330    _col_indices: &[usize],
331    rows: usize,
332    power: usize,
333) -> usize {
334    if rows == 0 || power == 0 {
335        return rows; // identity has `rows` non-zeros
336    }
337
338    let nnz = if row_offsets.len() > rows {
339        row_offsets[rows]
340    } else {
341        return 0;
342    };
343
344    if nnz == 0 {
345        return 0;
346    }
347
348    let avg_degree = nnz as f64 / rows as f64;
349    let estimated_degree = avg_degree.powi(power as i32);
350    let per_row = estimated_degree.min(rows as f64);
351    let total = (rows as f64 * per_row).min((rows * rows) as f64);
352    total as usize
353}
354
355/// Evaluate a polynomial `p(A) = c0*I + c1*A + c2*A^2 + ... + ck*A^k` using
356/// Horner's method.
357///
358/// Horner's form: `p(A) = ((...((ck*A + c_{k-1})*A + c_{k-2})*A + ...) + c1)*A + c0*I`
359///
360/// This requires only `k` multiplications rather than computing each `A^i`
361/// separately.
362///
363/// # Arguments
364///
365/// * CSR arrays and dimensions for `A` (must be square).
366/// * `coefficients` -- Polynomial coefficients `[c0, c1, ..., ck]`.
367///
368/// # Errors
369///
370/// Returns [`SparseError::DimensionMismatch`] if the matrix is not square,
371/// or [`SparseError::InvalidArgument`] if `coefficients` is empty.
372pub fn sparse_matrix_polynomial(
373    row_offsets: &[usize],
374    col_indices: &[usize],
375    values: &[f64],
376    rows: usize,
377    cols: usize,
378    coefficients: &[f64],
379) -> Result<MatrixPowerResult, SparseError> {
380    validate_csr(row_offsets, col_indices, values, rows)?;
381
382    if rows != cols {
383        return Err(SparseError::DimensionMismatch(format!(
384            "matrix must be square for polynomial evaluation, got {}x{}",
385            rows, cols
386        )));
387    }
388
389    if coefficients.is_empty() {
390        return Err(SparseError::InvalidArgument(
391            "polynomial coefficients must not be empty".to_string(),
392        ));
393    }
394
395    let n = rows;
396    let degree = coefficients.len() - 1;
397
398    // degree == 0 => c0 * I
399    if degree == 0 {
400        let (id_off, id_idx, id_val) = sparse_identity(n);
401        let (s_off, s_idx, s_val) = scalar_multiply_csr(&id_off, &id_idx, &id_val, coefficients[0]);
402        return Ok(MatrixPowerResult {
403            nnz: s_idx.len(),
404            row_offsets: s_off,
405            col_indices: s_idx,
406            values: s_val,
407            rows: n,
408            cols: n,
409            multiplications_performed: 0,
410            nnz_growth: vec![],
411        });
412    }
413
414    // Horner's method: start from highest coefficient
415    // result = ck
416    // for i in (0..k).rev():
417    //   result = result * A + c_i * I
418
419    let mut mults = 0usize;
420    let mut nnz_growth = Vec::new();
421
422    // Start: result = c_k * I
423    let (id_off, id_idx, id_val) = sparse_identity(n);
424    let (mut r_off, mut r_idx, mut r_val) =
425        scalar_multiply_csr(&id_off, &id_idx, &id_val, coefficients[degree]);
426
427    for i in (0..degree).rev() {
428        // result = result * A
429        let (prod_off, prod_idx, prod_val) = host_spgemm(
430            &r_off,
431            &r_idx,
432            &r_val,
433            n,
434            n,
435            row_offsets,
436            col_indices,
437            values,
438            n,
439            n,
440        )?;
441        mults += 1;
442        nnz_growth.push(prod_idx.len());
443
444        // result = result + c_i * I
445        let (ci_off, ci_idx, ci_val) =
446            scalar_multiply_csr(&id_off, &id_idx, &id_val, coefficients[i]);
447        let (sum_off, sum_idx, sum_val) = add_csr(
448            &prod_off, &prod_idx, &prod_val, &ci_off, &ci_idx, &ci_val, n,
449        )?;
450
451        r_off = sum_off;
452        r_idx = sum_idx;
453        r_val = sum_val;
454    }
455
456    Ok(MatrixPowerResult {
457        nnz: r_idx.len(),
458        row_offsets: r_off,
459        col_indices: r_idx,
460        values: r_val,
461        rows: n,
462        cols: n,
463        multiplications_performed: mults,
464        nnz_growth,
465    })
466}
467
468/// Multiply all values of a CSR matrix by a scalar, keeping the sparsity
469/// structure unchanged.
470///
471/// Returns `(row_offsets, col_indices, values)` -- offsets and indices are
472/// cloned, values are scaled.
473pub fn scalar_multiply_csr(
474    row_offsets: &[usize],
475    col_indices: &[usize],
476    values: &[f64],
477    scalar: f64,
478) -> (Vec<usize>, Vec<usize>, Vec<f64>) {
479    let scaled: Vec<f64> = values.iter().map(|&v| v * scalar).collect();
480    (row_offsets.to_vec(), col_indices.to_vec(), scaled)
481}
482
483/// Add two CSR matrices with the same number of rows: `C = A + B`.
484///
485/// Performs a merge of sorted column-index arrays per row, summing values
486/// where columns coincide.
487///
488/// # Errors
489///
490/// Returns [`SparseError::InvalidFormat`] if the row offset arrays are
491/// inconsistent.
492pub fn add_csr(
493    a_offsets: &[usize],
494    a_indices: &[usize],
495    a_values: &[f64],
496    b_offsets: &[usize],
497    b_indices: &[usize],
498    b_values: &[f64],
499    rows: usize,
500) -> Result<CsrTriple, SparseError> {
501    if a_offsets.len() != rows + 1 || b_offsets.len() != rows + 1 {
502        return Err(SparseError::InvalidFormat(format!(
503            "row_offsets length mismatch: expected {}, got A={} B={}",
504            rows + 1,
505            a_offsets.len(),
506            b_offsets.len()
507        )));
508    }
509
510    let mut c_offsets = Vec::with_capacity(rows + 1);
511    let mut c_indices = Vec::new();
512    let mut c_values = Vec::new();
513    c_offsets.push(0usize);
514
515    for i in 0..rows {
516        let mut ai = a_offsets[i];
517        let ae = a_offsets[i + 1];
518        let mut bi = b_offsets[i];
519        let be = b_offsets[i + 1];
520
521        while ai < ae && bi < be {
522            let ac = a_indices[ai];
523            let bc = b_indices[bi];
524            match ac.cmp(&bc) {
525                std::cmp::Ordering::Less => {
526                    c_indices.push(ac);
527                    c_values.push(a_values[ai]);
528                    ai += 1;
529                }
530                std::cmp::Ordering::Greater => {
531                    c_indices.push(bc);
532                    c_values.push(b_values[bi]);
533                    bi += 1;
534                }
535                std::cmp::Ordering::Equal => {
536                    c_indices.push(ac);
537                    c_values.push(a_values[ai] + b_values[bi]);
538                    ai += 1;
539                    bi += 1;
540                }
541            }
542        }
543        while ai < ae {
544            c_indices.push(a_indices[ai]);
545            c_values.push(a_values[ai]);
546            ai += 1;
547        }
548        while bi < be {
549            c_indices.push(b_indices[bi]);
550            c_values.push(b_values[bi]);
551            bi += 1;
552        }
553
554        c_offsets.push(c_indices.len());
555    }
556
557    Ok((c_offsets, c_indices, c_values))
558}
559
560// ---------------------------------------------------------------------------
561// Internal helpers
562// ---------------------------------------------------------------------------
563
564/// Validate basic CSR consistency.
565fn validate_csr(
566    row_offsets: &[usize],
567    col_indices: &[usize],
568    values: &[f64],
569    rows: usize,
570) -> Result<(), SparseError> {
571    if row_offsets.len() != rows + 1 {
572        return Err(SparseError::InvalidFormat(format!(
573            "row_offsets length should be {} but is {}",
574            rows + 1,
575            row_offsets.len()
576        )));
577    }
578    if col_indices.len() != values.len() {
579        return Err(SparseError::InvalidFormat(format!(
580            "col_indices length ({}) != values length ({})",
581            col_indices.len(),
582            values.len()
583        )));
584    }
585    let nnz = row_offsets.get(rows).copied().unwrap_or(0);
586    if col_indices.len() != nnz {
587        return Err(SparseError::InvalidFormat(format!(
588            "col_indices length ({}) != nnz from row_offsets ({})",
589            col_indices.len(),
590            nnz
591        )));
592    }
593    Ok(())
594}
595
596/// Check whether nnz exceeds the configured maximum, returning an error if so.
597fn check_max_nnz(nnz: usize, max: Option<usize>) -> Result<(), SparseError> {
598    if let Some(limit) = max {
599        if nnz > limit {
600            return Err(SparseError::InvalidArgument(format!(
601                "nnz ({}) exceeds max_nnz limit ({})",
602                nnz, limit
603            )));
604        }
605    }
606    Ok(())
607}
608
609// ---------------------------------------------------------------------------
610// Tests
611// ---------------------------------------------------------------------------
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    fn default_config(power: usize) -> MatrixPowerConfig {
618        MatrixPowerConfig {
619            max_nnz: None,
620            reuse_structure: false,
621            power,
622        }
623    }
624
625    // -- sparse_identity --
626
627    #[test]
628    fn test_sparse_identity() {
629        let (off, idx, val) = sparse_identity(4);
630        assert_eq!(off, vec![0, 1, 2, 3, 4]);
631        assert_eq!(idx, vec![0, 1, 2, 3]);
632        assert_eq!(val, vec![1.0, 1.0, 1.0, 1.0]);
633    }
634
635    #[test]
636    fn test_sparse_identity_zero() {
637        let (off, idx, val) = sparse_identity(0);
638        assert_eq!(off, vec![0]);
639        assert!(idx.is_empty());
640        assert!(val.is_empty());
641    }
642
643    // -- A^0 = I --
644
645    #[test]
646    fn test_power_zero_returns_identity() {
647        // 2x2 matrix [[1,2],[0,3]]
648        let off = vec![0, 2, 3];
649        let idx = vec![0, 1, 1];
650        let val = vec![1.0, 2.0, 3.0];
651        let config = default_config(0);
652        let res = sparse_matrix_power(&off, &idx, &val, 2, 2, 0, &config);
653        let r = res.expect("test: power 0 should succeed");
654        assert_eq!(r.rows, 2);
655        assert_eq!(r.cols, 2);
656        assert_eq!(r.nnz, 2);
657        assert_eq!(r.row_offsets, vec![0, 1, 2]);
658        assert_eq!(r.col_indices, vec![0, 1]);
659        assert_eq!(r.values, vec![1.0, 1.0]);
660        assert_eq!(r.multiplications_performed, 0);
661    }
662
663    // -- A^1 = A --
664
665    #[test]
666    fn test_power_one_returns_copy() {
667        let off = vec![0, 2, 3];
668        let idx = vec![0, 1, 1];
669        let val = vec![1.0, 2.0, 3.0];
670        let config = default_config(1);
671        let r = sparse_matrix_power(&off, &idx, &val, 2, 2, 1, &config)
672            .expect("test: power 1 should succeed");
673        assert_eq!(r.row_offsets, off);
674        assert_eq!(r.col_indices, idx);
675        assert_eq!(r.values, val);
676        assert_eq!(r.multiplications_performed, 0);
677    }
678
679    // -- A^2 for simple 3x3 --
680
681    #[test]
682    fn test_power_two_3x3() {
683        // A = [[1,0,0],[0,2,0],[0,0,3]]  (diagonal)
684        let off = vec![0, 1, 2, 3];
685        let idx = vec![0, 1, 2];
686        let val = vec![1.0, 2.0, 3.0];
687        let config = default_config(2);
688        let r = sparse_matrix_power(&off, &idx, &val, 3, 3, 2, &config)
689            .expect("test: power 2 should succeed");
690        // A^2 = diag(1, 4, 9)
691        assert_eq!(r.col_indices, vec![0, 1, 2]);
692        assert_eq!(r.values, vec![1.0, 4.0, 9.0]);
693    }
694
695    // -- binary exponentiation correctness --
696
697    #[test]
698    fn test_binary_vs_sequential_power4() {
699        // Non-diagonal 2x2: [[1,1],[0,1]]
700        // A^2 = [[1,2],[0,1]], A^3 = [[1,3],[0,1]], A^4 = [[1,4],[0,1]]
701        let off = vec![0, 2, 3];
702        let idx = vec![0, 1, 1];
703        let val = vec![1.0, 1.0, 1.0];
704
705        // Binary exponentiation (default)
706        let config = default_config(4);
707        let r = sparse_matrix_power(&off, &idx, &val, 2, 2, 4, &config)
708            .expect("test: binary exp power 4");
709
710        // Expected A^4 = [[1,4],[0,1]]
711        assert_eq!(r.row_offsets, vec![0, 2, 3]);
712        assert_eq!(r.col_indices, vec![0, 1, 1]);
713        assert!((r.values[0] - 1.0).abs() < 1e-12);
714        assert!((r.values[1] - 4.0).abs() < 1e-12);
715        assert!((r.values[2] - 1.0).abs() < 1e-12);
716    }
717
718    // -- max_nnz abort --
719
720    #[test]
721    fn test_max_nnz_abort() {
722        // Dense-ish 3x3 that will grow nnz on squaring
723        let off = vec![0, 3, 6, 9];
724        let idx = vec![0, 1, 2, 0, 1, 2, 0, 1, 2];
725        let val = vec![1.0; 9];
726        let config = MatrixPowerConfig {
727            max_nnz: Some(5), // way too small for 3x3 dense squared
728            reuse_structure: false,
729            power: 2,
730        };
731        let result = sparse_matrix_power(&off, &idx, &val, 3, 3, 2, &config);
732        assert!(result.is_err());
733        let err = result.unwrap_err();
734        let msg = err.to_string();
735        assert!(
736            msg.contains("max_nnz"),
737            "error should mention max_nnz: {}",
738            msg
739        );
740    }
741
742    // -- nnz growth tracking --
743
744    #[test]
745    fn test_nnz_growth_tracking() {
746        // Diagonal 3x3 -- nnz stays at 3
747        let off = vec![0, 1, 2, 3];
748        let idx = vec![0, 1, 2];
749        let val = vec![2.0, 3.0, 4.0];
750        let config = default_config(4);
751        let r = sparse_matrix_power(&off, &idx, &val, 3, 3, 4, &config).expect("test: nnz growth");
752        // nnz_growth should contain initial nnz plus entries from each mult
753        assert!(!r.nnz_growth.is_empty());
754        // For diagonal matrix, all entries should be 3
755        for &g in &r.nnz_growth {
756            assert_eq!(g, 3);
757        }
758    }
759
760    // -- host_spgemm 2x2 --
761
762    #[test]
763    fn test_host_spgemm_2x2() {
764        // A = [[1,2],[3,4]], B = [[5,6],[7,8]]
765        // C = A*B = [[19,22],[43,50]]
766        let a_off = vec![0, 2, 4];
767        let a_idx = vec![0, 1, 0, 1];
768        let a_val = vec![1.0, 2.0, 3.0, 4.0];
769        let b_off = vec![0, 2, 4];
770        let b_idx = vec![0, 1, 0, 1];
771        let b_val = vec![5.0, 6.0, 7.0, 8.0];
772
773        let (c_off, c_idx, c_val) =
774            host_spgemm(&a_off, &a_idx, &a_val, 2, 2, &b_off, &b_idx, &b_val, 2, 2)
775                .expect("test: spgemm 2x2");
776
777        assert_eq!(c_off, vec![0, 2, 4]);
778        assert_eq!(c_idx, vec![0, 1, 0, 1]);
779        assert!((c_val[0] - 19.0).abs() < 1e-12);
780        assert!((c_val[1] - 22.0).abs() < 1e-12);
781        assert!((c_val[2] - 43.0).abs() < 1e-12);
782        assert!((c_val[3] - 50.0).abs() < 1e-12);
783    }
784
785    // -- polynomial: p(A) = I + A for diagonal --
786
787    #[test]
788    fn test_polynomial_identity_plus_a() {
789        // A = diag(2, 3), p(A) = I + A = diag(3, 4)
790        let off = vec![0, 1, 2];
791        let idx = vec![0, 1];
792        let val = vec![2.0, 3.0];
793        let coeffs = [1.0, 1.0]; // c0=1, c1=1 => I + A
794
795        let r = sparse_matrix_polynomial(&off, &idx, &val, 2, 2, &coeffs)
796            .expect("test: polynomial I+A");
797        assert_eq!(r.col_indices, vec![0, 1]);
798        assert!((r.values[0] - 3.0).abs() < 1e-12);
799        assert!((r.values[1] - 4.0).abs() < 1e-12);
800    }
801
802    // -- scalar multiply --
803
804    #[test]
805    fn test_scalar_multiply() {
806        let off = vec![0, 2, 3];
807        let idx = vec![0, 1, 1];
808        let val = vec![1.0, 2.0, 3.0];
809        let (s_off, s_idx, s_val) = scalar_multiply_csr(&off, &idx, &val, 3.0);
810        assert_eq!(s_off, off);
811        assert_eq!(s_idx, idx);
812        assert_eq!(s_val, vec![3.0, 6.0, 9.0]);
813    }
814
815    // -- CSR addition --
816
817    #[test]
818    fn test_add_csr() {
819        // A = [[1,0],[0,2]], B = [[0,3],[4,0]]
820        let a_off = vec![0, 1, 2];
821        let a_idx = vec![0, 1];
822        let a_val = vec![1.0, 2.0];
823        let b_off = vec![0, 1, 2];
824        let b_idx = vec![1, 0];
825        let b_val = vec![3.0, 4.0];
826
827        let (c_off, c_idx, c_val) =
828            add_csr(&a_off, &a_idx, &a_val, &b_off, &b_idx, &b_val, 2).expect("test: add_csr");
829        // C = [[1,3],[4,2]]
830        assert_eq!(c_off, vec![0, 2, 4]);
831        assert_eq!(c_idx, vec![0, 1, 0, 1]);
832        assert!((c_val[0] - 1.0).abs() < 1e-12);
833        assert!((c_val[1] - 3.0).abs() < 1e-12);
834        assert!((c_val[2] - 4.0).abs() < 1e-12);
835        assert!((c_val[3] - 2.0).abs() < 1e-12);
836    }
837
838    // -- estimate_power_nnz --
839
840    #[test]
841    fn test_estimate_power_nnz() {
842        // 4x4 with 8 nnz => avg_degree = 2
843        let off = vec![0, 2, 4, 6, 8];
844        let idx = vec![0, 1, 1, 2, 2, 3, 0, 3];
845        let est = estimate_power_nnz(&off, &idx, 4, 2);
846        // avg_degree=2, power=2 => degree^2=4, per_row=min(4,4)=4, total=16
847        assert_eq!(est, 16);
848    }
849
850    #[test]
851    fn test_estimate_power_nnz_zero() {
852        let off = vec![0, 0, 0];
853        let idx: Vec<usize> = vec![];
854        let est = estimate_power_nnz(&off, &idx, 2, 3);
855        assert_eq!(est, 0);
856    }
857
858    // -- diagonal matrix power (element-wise power) --
859
860    #[test]
861    fn test_diagonal_power() {
862        // diag(2, 3, 5)^3 = diag(8, 27, 125)
863        let off = vec![0, 1, 2, 3];
864        let idx = vec![0, 1, 2];
865        let val = vec![2.0, 3.0, 5.0];
866        let config = default_config(3);
867        let r =
868            sparse_matrix_power(&off, &idx, &val, 3, 3, 3, &config).expect("test: diagonal power");
869        assert_eq!(r.col_indices, vec![0, 1, 2]);
870        assert!((r.values[0] - 8.0).abs() < 1e-12);
871        assert!((r.values[1] - 27.0).abs() < 1e-12);
872        assert!((r.values[2] - 125.0).abs() < 1e-12);
873    }
874
875    // -- Horner polynomial vs direct computation --
876
877    #[test]
878    fn test_horner_vs_direct() {
879        // A = diag(2, 3), p(x) = 1 + 2x + 3x^2
880        // p(2) = 1 + 4 + 12 = 17
881        // p(3) = 1 + 6 + 27 = 34
882        let off = vec![0, 1, 2];
883        let idx = vec![0, 1];
884        let val = vec![2.0, 3.0];
885        let coeffs = [1.0, 2.0, 3.0];
886
887        let r = sparse_matrix_polynomial(&off, &idx, &val, 2, 2, &coeffs)
888            .expect("test: Horner polynomial");
889        assert_eq!(r.col_indices, vec![0, 1]);
890        assert!((r.values[0] - 17.0).abs() < 1e-12);
891        assert!((r.values[1] - 34.0).abs() < 1e-12);
892    }
893
894    // -- empty matrix power --
895
896    #[test]
897    fn test_empty_matrix_power() {
898        let off = vec![0];
899        let idx: Vec<usize> = vec![];
900        let val: Vec<f64> = vec![];
901        let config = default_config(5);
902        // 0x0 matrix to power 0 => 0x0 identity
903        let r =
904            sparse_matrix_power(&off, &idx, &val, 0, 0, 0, &config).expect("test: empty power 0");
905        assert_eq!(r.rows, 0);
906        assert_eq!(r.cols, 0);
907        assert_eq!(r.nnz, 0);
908    }
909
910    // -- reuse_structure flag --
911
912    #[test]
913    fn test_reuse_structure_flag() {
914        // Just ensure it runs without error with reuse_structure = true
915        let off = vec![0, 1, 2];
916        let idx = vec![0, 1];
917        let val = vec![2.0, 3.0];
918        let config = MatrixPowerConfig {
919            max_nnz: None,
920            reuse_structure: true,
921            power: 4,
922        };
923        let r =
924            sparse_matrix_power(&off, &idx, &val, 2, 2, 4, &config).expect("test: reuse structure");
925        // diag(2,3)^4 = diag(16, 81)
926        assert!((r.values[0] - 16.0).abs() < 1e-12);
927        assert!((r.values[1] - 81.0).abs() < 1e-12);
928    }
929}