scirs2_linalg/sparse_tensor.rs
1//! Sparse tensor representations and operations in coordinate (COO) format.
2//!
3//! This module provides a generic, const-parameterised `SparseTensor<N>` for
4//! N-dimensional tensors stored in coordinate format, together with:
5//!
6//! - Conversion from / to dense `ndarray::ArrayD`
7//! - Sparse matrix-matrix product (SpMM) for 2-D sparse tensors
8//! - Tensor-vector mode product for 3-D sparse tensors
9//! - Non-zero ratio (sparsity statistics)
10//!
11//! ## Coordinate (COO) format
12//!
13//! Each non-zero element is represented by a [`SparseTensorEntry<N>`] that
14//! stores both the multi-index and the value. Duplicate indices are allowed
15//! and their values are summed whenever an operation requires it; the
16//! internal representation is kept in *unsorted, deduplicated* form by
17//! [`SparseTensor::canonicalize`].
18
19use crate::error::{LinalgError, LinalgResult};
20use scirs2_core::ndarray::{Array1, ArrayD, IxDyn};
21
22// ---------------------------------------------------------------------------
23// Entry type
24// ---------------------------------------------------------------------------
25
26/// A single non-zero entry in an N-dimensional sparse tensor.
27#[derive(Debug, Clone, PartialEq)]
28pub struct SparseTensorEntry<const N: usize> {
29 /// Multi-index of the entry.
30 pub indices: [usize; N],
31 /// Value of the entry.
32 pub value: f64,
33}
34
35impl<const N: usize> SparseTensorEntry<N> {
36 /// Create a new entry.
37 pub fn new(indices: [usize; N], value: f64) -> Self {
38 Self { indices, value }
39 }
40}
41
42// ---------------------------------------------------------------------------
43// SparseTensor
44// ---------------------------------------------------------------------------
45
46/// Sparse N-dimensional tensor in coordinate (COO) format.
47///
48/// The tensor stores only its non-zero entries together with their
49/// multi-indices. Entries with the same index can be present multiple times;
50/// call [`SparseTensor::canonicalize`] to merge them.
51///
52/// # Type parameters
53///
54/// * `N` – Tensor order (number of dimensions), fixed at compile time.
55#[derive(Debug, Clone)]
56pub struct SparseTensor<const N: usize> {
57 /// Shape of the tensor (length-N array of dimension sizes).
58 pub shape: [usize; N],
59 /// Non-zero entries.
60 pub entries: Vec<SparseTensorEntry<N>>,
61}
62
63impl<const N: usize> SparseTensor<N> {
64 /// Create an empty sparse tensor with the given shape.
65 pub fn new(shape: [usize; N]) -> Self {
66 Self {
67 shape,
68 entries: Vec::new(),
69 }
70 }
71
72 /// Add an entry to the tensor (does not check for duplicates).
73 pub fn push(&mut self, indices: [usize; N], value: f64) {
74 if value != 0.0 {
75 self.entries.push(SparseTensorEntry::new(indices, value));
76 }
77 }
78
79 /// Number of stored (non-zero) entries.
80 pub fn nnz(&self) -> usize {
81 self.entries.len()
82 }
83
84 /// Total number of elements (product of all dimensions).
85 pub fn size(&self) -> usize {
86 self.shape.iter().product()
87 }
88
89 /// Fraction of non-zero elements: `nnz / total_elements`.
90 ///
91 /// Returns `0.0` if the tensor has zero total elements.
92 pub fn nnz_ratio(&self) -> f64 {
93 let total = self.size();
94 if total == 0 {
95 0.0
96 } else {
97 self.entries.len() as f64 / total as f64
98 }
99 }
100
101 /// Sort entries lexicographically by index and merge duplicates by summing
102 /// their values.
103 ///
104 /// After calling this function, entries are in sorted order with no
105 /// duplicate indices.
106 pub fn canonicalize(&mut self) {
107 // Sort by indices (lexicographic)
108 self.entries.sort_by_key(|a| a.indices);
109
110 // Merge duplicates
111 let mut merged: Vec<SparseTensorEntry<N>> = Vec::with_capacity(self.entries.len());
112 for entry in self.entries.drain(..) {
113 if let Some(last) = merged.last_mut() {
114 if last.indices == entry.indices {
115 last.value += entry.value;
116 continue;
117 }
118 }
119 merged.push(entry);
120 }
121 // Remove exact zeros that may have appeared after merging
122 merged.retain(|e| e.value != 0.0);
123 self.entries = merged;
124 }
125}
126
127// ---------------------------------------------------------------------------
128// Conversion from dense
129// ---------------------------------------------------------------------------
130
131/// Build a [`SparseTensor<N>`] from a dense [`ArrayD<f64>`].
132///
133/// Elements whose absolute value is ≤ `threshold` are treated as zero and
134/// omitted from the sparse representation.
135///
136/// # Arguments
137///
138/// * `tensor` – Dense N-dimensional array.
139/// * `threshold` – Elements with `|value| <= threshold` are stored as zero.
140///
141/// # Errors
142///
143/// Returns [`LinalgError::DimensionError`] if the array's number of dimensions
144/// does not match `N`.
145///
146/// # Examples
147///
148/// ```
149/// use scirs2_core::ndarray::{ArrayD, IxDyn};
150/// use scirs2_linalg::sparse_tensor::{from_dense, SparseTensor};
151///
152/// let data = ArrayD::<f64>::from_shape_vec(
153/// IxDyn(&[2, 3]),
154/// vec![1.0, 0.0, 2.0, 0.0, 3.0, 0.0],
155/// ).expect("shape error");
156/// let sparse: SparseTensor<2> = from_dense(&data, 0.0).expect("conversion failed");
157/// assert_eq!(sparse.nnz(), 3);
158/// ```
159pub fn from_dense<const N: usize>(
160 tensor: &ArrayD<f64>,
161 threshold: f64,
162) -> LinalgResult<SparseTensor<N>> {
163 if tensor.ndim() != N {
164 return Err(LinalgError::DimensionError(format!(
165 "from_dense: array has {} dimensions, expected {}",
166 tensor.ndim(),
167 N
168 )));
169 }
170
171 let mut shape = [0usize; N];
172 for (i, &s) in tensor.shape().iter().enumerate() {
173 shape[i] = s;
174 }
175
176 let mut sparse = SparseTensor::new(shape);
177
178 for (linear_idx, &value) in tensor.iter().enumerate() {
179 if value.abs() <= threshold {
180 continue;
181 }
182 // Convert linear index to multi-index (row-major)
183 let mut indices = [0usize; N];
184 let mut remaining = linear_idx;
185 for dim in (0..N).rev() {
186 indices[dim] = remaining % shape[dim];
187 remaining /= shape[dim];
188 }
189 sparse.entries.push(SparseTensorEntry::new(indices, value));
190 }
191
192 Ok(sparse)
193}
194
195// ---------------------------------------------------------------------------
196// Conversion to dense
197// ---------------------------------------------------------------------------
198
199/// Reconstruct a dense [`ArrayD<f64>`] from a [`SparseTensor<N>`].
200///
201/// Duplicate entries are summed. Elements not present in the sparse tensor
202/// are zero in the output.
203///
204/// # Arguments
205///
206/// * `sparse` – Sparse tensor in COO format.
207/// * `shape` – Output shape (must match `sparse.shape`).
208///
209/// # Errors
210///
211/// Returns [`LinalgError::DimensionError`] if `shape` has the wrong length.
212/// Returns [`LinalgError::IndexError`] if any entry's index is out of bounds.
213///
214/// # Examples
215///
216/// ```
217/// use scirs2_core::ndarray::{ArrayD, IxDyn};
218/// use scirs2_linalg::sparse_tensor::{from_dense, to_dense, SparseTensor};
219///
220/// let data = ArrayD::<f64>::from_shape_vec(
221/// IxDyn(&[2, 3]),
222/// vec![1.0, 0.0, 2.0, 0.0, 3.0, 0.0],
223/// ).expect("shape error");
224/// let sparse: SparseTensor<2> = from_dense(&data, 0.0).expect("from_dense failed");
225/// let dense = to_dense(&sparse, &[2, 3]).expect("to_dense failed");
226/// assert_eq!(dense, data);
227/// ```
228pub fn to_dense<const N: usize>(
229 sparse: &SparseTensor<N>,
230 shape: &[usize],
231) -> LinalgResult<ArrayD<f64>> {
232 if shape.len() != N {
233 return Err(LinalgError::DimensionError(format!(
234 "to_dense: shape slice has {} elements, expected {}",
235 shape.len(),
236 N
237 )));
238 }
239
240 let mut out = ArrayD::<f64>::zeros(IxDyn(shape));
241
242 for entry in &sparse.entries {
243 // Build the IxDyn index
244 let idx = IxDyn(entry.indices.as_slice());
245 // Bounds check
246 for dim in 0..N {
247 if entry.indices[dim] >= shape[dim] {
248 return Err(LinalgError::IndexError(format!(
249 "to_dense: entry index {} in dimension {} is out of bounds (size {})",
250 entry.indices[dim], dim, shape[dim]
251 )));
252 }
253 }
254 out[idx] += entry.value;
255 }
256
257 Ok(out)
258}
259
260// ---------------------------------------------------------------------------
261// Sparse matrix-matrix product (SpMM) for 2-D sparse tensors
262// ---------------------------------------------------------------------------
263
264/// Sparse matrix-matrix multiplication `C = A * B` for 2-D sparse tensors.
265///
266/// Computes the matrix product of two sparse matrices in COO format.
267/// The result is returned as a new sparse tensor in COO format (not yet
268/// canonicalized; call [`SparseTensor::canonicalize`] if needed).
269///
270/// # Arguments
271///
272/// * `a` – Left-hand sparse matrix of shape `[m, k]`.
273/// * `b` – Right-hand sparse matrix of shape `[k, n]`.
274///
275/// # Errors
276///
277/// Returns [`LinalgError::DimensionError`] if the inner dimensions do not match.
278///
279/// # Examples
280///
281/// ```
282/// use scirs2_linalg::sparse_tensor::{SparseTensor, sparse_tensor_product};
283///
284/// // Identity matrices
285/// let mut eye2: SparseTensor<2> = SparseTensor::new([2, 2]);
286/// eye2.push([0, 0], 1.0);
287/// eye2.push([1, 1], 1.0);
288///
289/// let mut b: SparseTensor<2> = SparseTensor::new([2, 2]);
290/// b.push([0, 0], 3.0);
291/// b.push([1, 1], 4.0);
292///
293/// let c = sparse_tensor_product(&eye2, &b).expect("SpMM failed");
294/// assert_eq!(c.nnz(), 2);
295/// ```
296pub fn sparse_tensor_product(
297 a: &SparseTensor<2>,
298 b: &SparseTensor<2>,
299) -> LinalgResult<SparseTensor<2>> {
300 let m = a.shape[0];
301 let k_a = a.shape[1];
302 let k_b = b.shape[0];
303 let n = b.shape[1];
304
305 if k_a != k_b {
306 return Err(LinalgError::DimensionError(format!(
307 "sparse_tensor_product: A has {} columns but B has {} rows",
308 k_a, k_b
309 )));
310 }
311
312 // Group B entries by row index for fast lookup
313 // b_by_row[row] = list of (col, value)
314 let mut b_by_row: Vec<Vec<(usize, f64)>> = vec![Vec::new(); k_b];
315 for entry in &b.entries {
316 b_by_row[entry.indices[0]].push((entry.indices[1], entry.value));
317 }
318
319 let mut c = SparseTensor::new([m, n]);
320
321 for a_entry in &a.entries {
322 let row_a = a_entry.indices[0];
323 let col_a = a_entry.indices[1]; // = shared dimension k
324 let val_a = a_entry.value;
325
326 for &(col_b, val_b) in &b_by_row[col_a] {
327 c.entries
328 .push(SparseTensorEntry::new([row_a, col_b], val_a * val_b));
329 }
330 }
331
332 // Merge duplicate entries
333 c.canonicalize();
334 Ok(c)
335}
336
337// ---------------------------------------------------------------------------
338// Tensor-vector mode product (for 3-D sparse tensors)
339// ---------------------------------------------------------------------------
340
341/// Multiply a 3-D sparse tensor by a dense vector along a given mode.
342///
343/// The n-mode product of an order-3 tensor `T` of shape `[I, J, K]` with a
344/// vector `v` of length `dim_mode` contracts the specified mode:
345///
346/// - `mode = 0`: result shape `[J, K]`, `C[j,k] = Σ_i T[i,j,k] * v[i]`
347/// - `mode = 1`: result shape `[I, K]`, `C[i,k] = Σ_j T[i,j,k] * v[j]`
348/// - `mode = 2`: result shape `[I, J]`, `C[i,j] = Σ_k T[i,j,k] * v[k]`
349///
350/// # Arguments
351///
352/// * `t` – Sparse 3-D tensor of shape `[I, J, K]`.
353/// * `v` – Dense vector of length equal to `t.shape[mode]`.
354/// * `mode` – Mode index (0, 1, or 2).
355///
356/// # Returns
357///
358/// A [`SparseTensor<2>`] representing the contracted matrix (shape depends on
359/// `mode`).
360///
361/// # Errors
362///
363/// Returns [`LinalgError::ValueError`] if `mode >= 3`.
364/// Returns [`LinalgError::DimensionError`] if `v.len() != t.shape[mode]`.
365///
366/// # Examples
367///
368/// ```
369/// use scirs2_core::ndarray::array;
370/// use scirs2_linalg::sparse_tensor::{SparseTensor, tensor_times_vector};
371///
372/// // 2×2×2 identity-like tensor: T[i,i,i] = 1
373/// let mut t: SparseTensor<3> = SparseTensor::new([2, 2, 2]);
374/// t.push([0, 0, 0], 1.0);
375/// t.push([1, 1, 1], 1.0);
376///
377/// let v = array![3.0_f64, 5.0];
378/// let c = tensor_times_vector(&t, &v, 2).expect("TTV failed");
379/// // C[0,0] = T[0,0,0] * v[0] = 3.0
380/// // C[1,1] = T[1,1,1] * v[1] = 5.0
381/// assert_eq!(c.nnz(), 2);
382/// ```
383pub fn tensor_times_vector(
384 t: &SparseTensor<3>,
385 v: &Array1<f64>,
386 mode: usize,
387) -> LinalgResult<SparseTensor<2>> {
388 if mode >= 3 {
389 return Err(LinalgError::ValueError(format!(
390 "tensor_times_vector: mode must be 0, 1, or 2, got {}",
391 mode
392 )));
393 }
394 if v.len() != t.shape[mode] {
395 return Err(LinalgError::DimensionError(format!(
396 "tensor_times_vector: vector length {} does not match tensor dimension {} in mode {}",
397 v.len(),
398 t.shape[mode],
399 mode
400 )));
401 }
402
403 // Compute output shape
404 let out_shape: [usize; 2] = match mode {
405 0 => [t.shape[1], t.shape[2]],
406 1 => [t.shape[0], t.shape[2]],
407 2 => [t.shape[0], t.shape[1]],
408 _ => unreachable!(),
409 };
410
411 let mut result: SparseTensor<2> = SparseTensor::new(out_shape);
412
413 for entry in &t.entries {
414 let [i, j, k] = entry.indices;
415 let contracted_idx = match mode {
416 0 => i,
417 1 => j,
418 2 => k,
419 _ => unreachable!(),
420 };
421 let v_val = v[contracted_idx];
422 let product = entry.value * v_val;
423 if product == 0.0 {
424 continue;
425 }
426 let out_idx: [usize; 2] = match mode {
427 0 => [j, k],
428 1 => [i, k],
429 2 => [i, j],
430 _ => unreachable!(),
431 };
432 result
433 .entries
434 .push(SparseTensorEntry::new(out_idx, product));
435 }
436
437 result.canonicalize();
438 Ok(result)
439}
440
441// ---------------------------------------------------------------------------
442// nnz_ratio convenience function
443// ---------------------------------------------------------------------------
444
445/// Return the fraction of non-zero entries in a sparse tensor.
446///
447/// This is a free-function wrapper around [`SparseTensor::nnz_ratio`].
448///
449/// # Examples
450///
451/// ```
452/// use scirs2_core::ndarray::{ArrayD, IxDyn};
453/// use scirs2_linalg::sparse_tensor::{from_dense, nnz_ratio};
454///
455/// let data = ArrayD::<f64>::from_shape_vec(
456/// IxDyn(&[2, 4]),
457/// vec![1.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0],
458/// ).expect("shape error");
459/// let sparse = from_dense::<2>(&data, 0.0).expect("from_dense failed");
460/// let ratio = nnz_ratio(&sparse);
461/// assert!((ratio - 0.25).abs() < 1e-15, "expected 0.25, got {}", ratio);
462/// ```
463pub fn nnz_ratio<const N: usize>(sparse: &SparseTensor<N>) -> f64 {
464 sparse.nnz_ratio()
465}
466
467// ---------------------------------------------------------------------------
468// Tests
469// ---------------------------------------------------------------------------
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use scirs2_core::ndarray::{array, ArrayD, IxDyn};
475
476 // ---- from_dense / to_dense ----
477
478 #[test]
479 fn test_from_dense_basic() {
480 let data =
481 ArrayD::<f64>::from_shape_vec(IxDyn(&[2, 3]), vec![1.0, 0.0, 2.0, 0.0, 3.0, 0.0])
482 .expect("shape error");
483 let sparse: SparseTensor<2> = from_dense(&data, 0.0).expect("from_dense failed");
484 assert_eq!(sparse.nnz(), 3);
485 assert_eq!(sparse.shape, [2, 3]);
486 }
487
488 #[test]
489 fn test_from_dense_threshold() {
490 let data =
491 ArrayD::<f64>::from_shape_vec(IxDyn(&[3]), vec![1.0, 0.1, 1e-10]).expect("shape error");
492 let sparse: SparseTensor<1> = from_dense(&data, 1e-9).expect("from_dense failed");
493 // 1e-10 <= 1e-9, so only 2 entries should survive
494 assert_eq!(sparse.nnz(), 2);
495 }
496
497 #[test]
498 fn test_to_dense_roundtrip() {
499 let data =
500 ArrayD::<f64>::from_shape_vec(IxDyn(&[2, 3]), vec![1.0, 0.0, 2.0, 0.0, 3.0, 0.0])
501 .expect("shape error");
502 let sparse: SparseTensor<2> = from_dense(&data, 0.0).expect("from_dense failed");
503 let dense = to_dense(&sparse, &[2, 3]).expect("to_dense failed");
504 assert_eq!(dense, data);
505 }
506
507 #[test]
508 fn test_to_dense_duplicates_summed() {
509 let mut sparse: SparseTensor<2> = SparseTensor::new([2, 2]);
510 sparse.push([0, 0], 1.0);
511 sparse.push([0, 0], 2.0); // duplicate; should sum to 3
512 sparse.push([1, 1], 4.0);
513 let dense = to_dense(&sparse, &[2, 2]).expect("to_dense failed");
514 assert!((dense[[0, 0]] - 3.0).abs() < 1e-15);
515 assert!((dense[[1, 1]] - 4.0).abs() < 1e-15);
516 assert_eq!(dense[[0, 1]], 0.0);
517 assert_eq!(dense[[1, 0]], 0.0);
518 }
519
520 #[test]
521 fn test_from_dense_wrong_dims() {
522 let data =
523 ArrayD::<f64>::from_shape_vec(IxDyn(&[2, 3, 4]), vec![0.0; 24]).expect("shape error");
524 // Trying to create SparseTensor<2> from a 3-D array should fail
525 let result = from_dense::<2>(&data, 0.0);
526 assert!(result.is_err());
527 }
528
529 // ---- nnz_ratio ----
530
531 #[test]
532 fn test_nnz_ratio() {
533 let data = ArrayD::<f64>::from_shape_vec(
534 IxDyn(&[2, 4]),
535 vec![1.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0],
536 )
537 .expect("shape error");
538 let sparse: SparseTensor<2> = from_dense(&data, 0.0).expect("from_dense failed");
539 let r = nnz_ratio(&sparse);
540 assert!((r - 0.25).abs() < 1e-15, "expected 0.25, got {}", r);
541 }
542
543 #[test]
544 fn test_nnz_ratio_empty() {
545 let sparse: SparseTensor<2> = SparseTensor::new([0, 0]);
546 assert_eq!(nnz_ratio(&sparse), 0.0);
547 }
548
549 // ---- sparse_tensor_product ----
550
551 #[test]
552 fn test_spmm_identity() {
553 // I * B = B
554 let mut eye: SparseTensor<2> = SparseTensor::new([3, 3]);
555 eye.push([0, 0], 1.0);
556 eye.push([1, 1], 1.0);
557 eye.push([2, 2], 1.0);
558
559 let mut b: SparseTensor<2> = SparseTensor::new([3, 2]);
560 b.push([0, 0], 1.0);
561 b.push([1, 1], 2.0);
562 b.push([2, 0], 3.0);
563
564 let c = sparse_tensor_product(&eye, &b).expect("SpMM failed");
565 let dense_c = to_dense(&c, &[3, 2]).expect("to_dense failed");
566 let dense_b = to_dense(&b, &[3, 2]).expect("to_dense b failed");
567 for i in 0..3 {
568 for j in 0..2 {
569 assert!(
570 (dense_c[[i, j]] - dense_b[[i, j]]).abs() < 1e-12,
571 "I*B != B at [{i},{j}]"
572 );
573 }
574 }
575 }
576
577 #[test]
578 fn test_spmm_general() {
579 // A = [[1,2],[3,4]] B = [[5,6],[7,8]] C = A*B
580 let mut a: SparseTensor<2> = SparseTensor::new([2, 2]);
581 a.push([0, 0], 1.0);
582 a.push([0, 1], 2.0);
583 a.push([1, 0], 3.0);
584 a.push([1, 1], 4.0);
585
586 let mut b: SparseTensor<2> = SparseTensor::new([2, 2]);
587 b.push([0, 0], 5.0);
588 b.push([0, 1], 6.0);
589 b.push([1, 0], 7.0);
590 b.push([1, 1], 8.0);
591
592 let c = sparse_tensor_product(&a, &b).expect("SpMM failed");
593 let dense = to_dense(&c, &[2, 2]).expect("to_dense failed");
594 // A*B = [[19, 22], [43, 50]]
595 assert!((dense[[0, 0]] - 19.0).abs() < 1e-12);
596 assert!((dense[[0, 1]] - 22.0).abs() < 1e-12);
597 assert!((dense[[1, 0]] - 43.0).abs() < 1e-12);
598 assert!((dense[[1, 1]] - 50.0).abs() < 1e-12);
599 }
600
601 #[test]
602 fn test_spmm_dimension_mismatch() {
603 let a: SparseTensor<2> = SparseTensor::new([2, 3]);
604 let b: SparseTensor<2> = SparseTensor::new([4, 2]); // inner dim mismatch
605 assert!(sparse_tensor_product(&a, &b).is_err());
606 }
607
608 // ---- tensor_times_vector ----
609
610 #[test]
611 fn test_ttv_mode0() {
612 // T[i,j,k] = delta_{ijk} (identity-like)
613 let mut t: SparseTensor<3> = SparseTensor::new([2, 2, 2]);
614 t.push([0, 0, 0], 1.0);
615 t.push([1, 1, 1], 1.0);
616
617 let v = array![2.0_f64, 3.0];
618 // mode=0: C[j,k] = sum_i T[i,j,k]*v[i]
619 // C[0,0] = T[0,0,0]*v[0] = 2
620 // C[1,1] = T[1,1,1]*v[1] = 3
621 let c = tensor_times_vector(&t, &v, 0).expect("TTV failed");
622 assert_eq!(c.shape, [2, 2]);
623 let dense = to_dense(&c, &[2, 2]).expect("to_dense failed");
624 assert!((dense[[0, 0]] - 2.0).abs() < 1e-12);
625 assert!((dense[[1, 1]] - 3.0).abs() < 1e-12);
626 }
627
628 #[test]
629 fn test_ttv_mode2() {
630 let mut t: SparseTensor<3> = SparseTensor::new([2, 2, 2]);
631 t.push([0, 0, 0], 1.0);
632 t.push([0, 1, 1], 2.0);
633 t.push([1, 0, 0], 3.0);
634 t.push([1, 1, 1], 4.0);
635
636 let v = array![5.0_f64, 7.0];
637 // mode=2: C[i,j] = sum_k T[i,j,k]*v[k]
638 // C[0,0] = 1*5 = 5
639 // C[0,1] = 2*7 = 14
640 // C[1,0] = 3*5 = 15
641 // C[1,1] = 4*7 = 28
642 let c = tensor_times_vector(&t, &v, 2).expect("TTV failed");
643 let dense = to_dense(&c, &[2, 2]).expect("to_dense failed");
644 assert!((dense[[0, 0]] - 5.0).abs() < 1e-12);
645 assert!((dense[[0, 1]] - 14.0).abs() < 1e-12);
646 assert!((dense[[1, 0]] - 15.0).abs() < 1e-12);
647 assert!((dense[[1, 1]] - 28.0).abs() < 1e-12);
648 }
649
650 #[test]
651 fn test_ttv_wrong_mode() {
652 let t: SparseTensor<3> = SparseTensor::new([2, 2, 2]);
653 let v = array![1.0_f64];
654 assert!(tensor_times_vector(&t, &v, 3).is_err());
655 }
656
657 #[test]
658 fn test_ttv_dim_mismatch() {
659 let t: SparseTensor<3> = SparseTensor::new([2, 3, 4]);
660 let v = array![1.0_f64, 2.0, 3.0]; // length 3, but mode 0 expects 2
661 assert!(tensor_times_vector(&t, &v, 0).is_err());
662 }
663
664 // ---- canonicalize ----
665
666 #[test]
667 fn test_canonicalize_merges_duplicates() {
668 let mut sparse: SparseTensor<2> = SparseTensor::new([3, 3]);
669 sparse.push([0, 0], 1.0);
670 sparse.push([0, 0], 2.0);
671 sparse.push([1, 2], 3.0);
672 sparse.canonicalize();
673 assert_eq!(sparse.nnz(), 2);
674 let e00 = sparse.entries.iter().find(|e| e.indices == [0, 0]);
675 assert!(e00.is_some());
676 assert!((e00.expect("missing").value - 3.0).abs() < 1e-15);
677 }
678
679 #[test]
680 fn test_canonicalize_removes_zeros() {
681 let mut sparse: SparseTensor<1> = SparseTensor::new([4]);
682 sparse.entries.push(SparseTensorEntry::new([0], 1.0));
683 sparse.entries.push(SparseTensorEntry::new([0], -1.0)); // sum = 0
684 sparse.entries.push(SparseTensorEntry::new([2], 5.0));
685 sparse.canonicalize();
686 assert_eq!(sparse.nnz(), 1);
687 assert_eq!(sparse.entries[0].indices, [2]);
688 }
689
690 // ---- size / nnz ----
691
692 #[test]
693 fn test_size_and_nnz() {
694 let data =
695 ArrayD::<f64>::from_shape_vec(IxDyn(&[3, 4, 5]), (0..60).map(|x| x as f64).collect())
696 .expect("shape error");
697 let sparse: SparseTensor<3> = from_dense(&data, 0.5).expect("from_dense failed");
698 assert_eq!(sparse.size(), 60);
699 // All values 1..59 plus 0 is filtered; 1..59 remain = 59
700 assert_eq!(sparse.nnz(), 59);
701 }
702}