Skip to main content

oxicuda_sparse/ops/
tensor.rs

1//! Sparse tensor operations for GNN (Graph Neural Network) workloads.
2//!
3//! This module extends SpMV/SpMM primitives with operations tailored for
4//! graph neural network message passing and aggregation, including:
5//!
6//! - [`scatter_reduce`] -- Scatter-reduce with configurable aggregation
7//! - [`gather`] -- Index-based gathering from source arrays
8//! - [`sparse_message_passing`] -- GNN message passing over adjacency matrices
9//! - [`sparse_attention_message`] -- Attention-weighted message passing (GAT-style)
10//! - [`compute_degree_matrix`] -- Node degree computation from CSR offsets
11//! - [`symmetric_normalize`] -- D^{-1/2} A D^{-1/2} normalization (GCN-style)
12//! - [`add_self_loops`] -- Add identity self-loops to adjacency matrix
13//! - [`sparse_row_softmax`] -- Row-wise softmax over sparse matrix values
14//! - [`generate_message_passing_ptx`] -- PTX kernel generation for GPU message passing
15//!
16//! (C) 2026 COOLJAPAN OU (Team KitaSan)
17
18use crate::error::SparseError;
19
20// ---------------------------------------------------------------------------
21// Types
22// ---------------------------------------------------------------------------
23
24/// Aggregation operation for GNN message passing.
25///
26/// Controls how incoming messages from neighboring nodes are combined.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum MessagePassingOp {
29    /// Sum of all incoming messages.
30    Sum,
31    /// Mean (average) of all incoming messages.
32    Mean,
33    /// Element-wise maximum of incoming messages.
34    Max,
35    /// Element-wise minimum of incoming messages.
36    Min,
37}
38
39/// Configuration for GNN sparse operations.
40#[derive(Debug, Clone)]
41pub struct GnnSparseConfig {
42    /// Number of nodes in the graph.
43    pub num_nodes: usize,
44    /// Dimensionality of per-node features.
45    pub feature_dim: usize,
46    /// Number of edges in the graph.
47    pub num_edges: usize,
48    /// Aggregation operation for message passing.
49    pub op: MessagePassingOp,
50    /// Whether to normalize aggregated messages by node degree.
51    pub normalize: bool,
52}
53
54/// Edge features for attention-weighted message passing.
55///
56/// Stores per-edge feature values used in attention mechanisms (e.g. GAT).
57#[derive(Debug, Clone)]
58pub struct EdgeFeatures {
59    /// Per-edge feature values.
60    pub values: Vec<f64>,
61    /// Number of edges (must equal `values.len()`).
62    pub edge_count: usize,
63}
64
65// ---------------------------------------------------------------------------
66// Core operations
67// ---------------------------------------------------------------------------
68
69/// Scatter-reduce operation.
70///
71/// For each position `i` in `src`, the value `src[i]` is aggregated into
72/// `output[index[i]]` using the specified [`MessagePassingOp`].
73///
74/// # Arguments
75///
76/// * `src` -- Source values to scatter.
77/// * `index` -- Target indices; must have same length as `src`.
78/// * `num_targets` -- Size of the output vector.
79/// * `op` -- Aggregation operation.
80///
81/// # Errors
82///
83/// Returns [`SparseError::DimensionMismatch`] if `src` and `index` differ in length.
84/// Returns [`SparseError::InvalidArgument`] if any index is out of bounds.
85pub fn scatter_reduce(
86    src: &[f64],
87    index: &[usize],
88    num_targets: usize,
89    op: MessagePassingOp,
90) -> Result<Vec<f64>, SparseError> {
91    if src.len() != index.len() {
92        return Err(SparseError::DimensionMismatch(format!(
93            "src length {} != index length {}",
94            src.len(),
95            index.len()
96        )));
97    }
98
99    // Validate all indices
100    for (i, &idx) in index.iter().enumerate() {
101        if idx >= num_targets {
102            return Err(SparseError::InvalidArgument(format!(
103                "index[{}] = {} is out of bounds for num_targets = {}",
104                i, idx, num_targets
105            )));
106        }
107    }
108
109    let init_val = match op {
110        MessagePassingOp::Sum | MessagePassingOp::Mean => 0.0_f64,
111        MessagePassingOp::Max => f64::NEG_INFINITY,
112        MessagePassingOp::Min => f64::INFINITY,
113    };
114
115    let mut output = vec![init_val; num_targets];
116    let mut counts = if matches!(op, MessagePassingOp::Mean) {
117        vec![0usize; num_targets]
118    } else {
119        Vec::new()
120    };
121
122    for (i, &idx) in index.iter().enumerate() {
123        match op {
124            MessagePassingOp::Sum => {
125                output[idx] += src[i];
126            }
127            MessagePassingOp::Mean => {
128                output[idx] += src[i];
129                counts[idx] += 1;
130            }
131            MessagePassingOp::Max => {
132                if src[i] > output[idx] {
133                    output[idx] = src[i];
134                }
135            }
136            MessagePassingOp::Min => {
137                if src[i] < output[idx] {
138                    output[idx] = src[i];
139                }
140            }
141        }
142    }
143
144    // Finalize Mean: divide by count; targets with zero contributions stay 0.
145    if matches!(op, MessagePassingOp::Mean) {
146        for (val, &cnt) in output.iter_mut().zip(counts.iter()) {
147            if cnt > 0 {
148                *val /= cnt as f64;
149            } else {
150                *val = 0.0;
151            }
152        }
153    }
154
155    // For Max/Min with no contributions, set to 0.
156    if matches!(op, MessagePassingOp::Max | MessagePassingOp::Min) {
157        for val in &mut output {
158            if *val == f64::NEG_INFINITY || *val == f64::INFINITY {
159                *val = 0.0;
160            }
161        }
162    }
163
164    Ok(output)
165}
166
167/// Gather operation.
168///
169/// Collects values from `src` at the given `index` positions,
170/// producing a vector of length `index.len()`.
171///
172/// # Errors
173///
174/// Returns [`SparseError::InvalidArgument`] if any index is out of bounds.
175pub fn gather(src: &[f64], index: &[usize]) -> Result<Vec<f64>, SparseError> {
176    let mut result = Vec::with_capacity(index.len());
177    for (i, &idx) in index.iter().enumerate() {
178        if idx >= src.len() {
179            return Err(SparseError::InvalidArgument(format!(
180                "gather index[{}] = {} out of bounds for src of length {}",
181                i,
182                idx,
183                src.len()
184            )));
185        }
186        result.push(src[idx]);
187    }
188    Ok(result)
189}
190
191// ---------------------------------------------------------------------------
192// GNN message passing
193// ---------------------------------------------------------------------------
194
195/// Sparse message passing over an adjacency matrix in CSR form.
196///
197/// For each node `i`, aggregates features from its neighbors using the
198/// adjacency structure:
199///
200/// ```text
201/// output[i, :] = aggregate_{j in neighbors(i)} features[j, :]
202/// ```
203///
204/// When `config.normalize` is `true`, the aggregated result is divided
205/// by the degree of node `i`.
206///
207/// # Arguments
208///
209/// * `adj_row_offsets` -- CSR row pointer array (length `num_nodes + 1`).
210/// * `adj_col_indices` -- CSR column indices (length `num_edges`).
211/// * `node_features` -- Dense feature matrix stored row-major (num_nodes x feature_dim).
212/// * `feature_dim` -- Number of features per node.
213/// * `config` -- GNN configuration (aggregation op, normalization, etc.).
214///
215/// # Errors
216///
217/// Returns [`SparseError::DimensionMismatch`] on inconsistent sizes.
218pub fn sparse_message_passing(
219    adj_row_offsets: &[usize],
220    adj_col_indices: &[usize],
221    node_features: &[f64],
222    feature_dim: usize,
223    config: &GnnSparseConfig,
224) -> Result<Vec<f64>, SparseError> {
225    let num_nodes = config.num_nodes;
226
227    if adj_row_offsets.len() != num_nodes + 1 {
228        return Err(SparseError::DimensionMismatch(format!(
229            "adj_row_offsets length {} != num_nodes + 1 = {}",
230            adj_row_offsets.len(),
231            num_nodes + 1
232        )));
233    }
234    if feature_dim != config.feature_dim {
235        return Err(SparseError::DimensionMismatch(format!(
236            "feature_dim {} != config.feature_dim {}",
237            feature_dim, config.feature_dim
238        )));
239    }
240    if node_features.len() != num_nodes * feature_dim {
241        return Err(SparseError::DimensionMismatch(format!(
242            "node_features length {} != num_nodes * feature_dim = {}",
243            node_features.len(),
244            num_nodes * feature_dim
245        )));
246    }
247
248    let mut output = vec![0.0_f64; num_nodes * feature_dim];
249
250    for i in 0..num_nodes {
251        let start = adj_row_offsets[i];
252        let end = adj_row_offsets[i + 1];
253        let degree = end - start;
254
255        if degree == 0 {
256            continue;
257        }
258
259        match config.op {
260            MessagePassingOp::Sum | MessagePassingOp::Mean => {
261                for &j in &adj_col_indices[start..end] {
262                    if j >= num_nodes {
263                        return Err(SparseError::InvalidArgument(format!(
264                            "column index {} out of bounds for {} nodes",
265                            j, num_nodes
266                        )));
267                    }
268                    for f in 0..feature_dim {
269                        output[i * feature_dim + f] += node_features[j * feature_dim + f];
270                    }
271                }
272                if config.op == MessagePassingOp::Mean || config.normalize {
273                    let inv_degree = 1.0 / degree as f64;
274                    for f in 0..feature_dim {
275                        output[i * feature_dim + f] *= inv_degree;
276                    }
277                }
278            }
279            MessagePassingOp::Max => {
280                // Initialize to negative infinity, then take max
281                for f in 0..feature_dim {
282                    output[i * feature_dim + f] = f64::NEG_INFINITY;
283                }
284                for &j in &adj_col_indices[start..end] {
285                    if j >= num_nodes {
286                        return Err(SparseError::InvalidArgument(format!(
287                            "column index {} out of bounds for {} nodes",
288                            j, num_nodes
289                        )));
290                    }
291                    for f in 0..feature_dim {
292                        let val = node_features[j * feature_dim + f];
293                        if val > output[i * feature_dim + f] {
294                            output[i * feature_dim + f] = val;
295                        }
296                    }
297                }
298                // Replace any remaining -inf with 0 (isolated nodes handled above)
299            }
300            MessagePassingOp::Min => {
301                for f in 0..feature_dim {
302                    output[i * feature_dim + f] = f64::INFINITY;
303                }
304                for &j in &adj_col_indices[start..end] {
305                    if j >= num_nodes {
306                        return Err(SparseError::InvalidArgument(format!(
307                            "column index {} out of bounds for {} nodes",
308                            j, num_nodes
309                        )));
310                    }
311                    for f in 0..feature_dim {
312                        let val = node_features[j * feature_dim + f];
313                        if val < output[i * feature_dim + f] {
314                            output[i * feature_dim + f] = val;
315                        }
316                    }
317                }
318            }
319        }
320    }
321
322    Ok(output)
323}
324
325/// Attention-weighted message passing.
326///
327/// For each node `i`, computes:
328///
329/// ```text
330/// output[i, :] = sum_{j in N(i)} alpha_ij * features[j, :]
331/// ```
332///
333/// where `attention_weights` provides one scalar weight per edge.
334///
335/// # Arguments
336///
337/// * `adj_row_offsets` -- CSR row pointer (length num_nodes + 1).
338/// * `adj_col_indices` -- CSR column indices (length num_edges).
339/// * `node_features` -- Dense feature matrix, row-major (num_nodes x feature_dim).
340/// * `attention_weights` -- One weight per edge (length num_edges).
341/// * `feature_dim` -- Dimensionality of node features.
342///
343/// # Errors
344///
345/// Returns [`SparseError::DimensionMismatch`] on size mismatches.
346pub fn sparse_attention_message(
347    adj_row_offsets: &[usize],
348    adj_col_indices: &[usize],
349    node_features: &[f64],
350    attention_weights: &[f64],
351    feature_dim: usize,
352) -> Result<Vec<f64>, SparseError> {
353    if adj_row_offsets.is_empty() {
354        return Err(SparseError::InvalidArgument(
355            "adj_row_offsets must not be empty".to_string(),
356        ));
357    }
358    let num_nodes = adj_row_offsets.len() - 1;
359    let num_edges = adj_col_indices.len();
360
361    if attention_weights.len() != num_edges {
362        return Err(SparseError::DimensionMismatch(format!(
363            "attention_weights length {} != num_edges {}",
364            attention_weights.len(),
365            num_edges
366        )));
367    }
368    if node_features.len() != num_nodes * feature_dim {
369        return Err(SparseError::DimensionMismatch(format!(
370            "node_features length {} != num_nodes * feature_dim = {}",
371            node_features.len(),
372            num_nodes * feature_dim
373        )));
374    }
375
376    let mut output = vec![0.0_f64; num_nodes * feature_dim];
377
378    for i in 0..num_nodes {
379        let start = adj_row_offsets[i];
380        let end = adj_row_offsets[i + 1];
381
382        for edge_idx in start..end {
383            let j = adj_col_indices[edge_idx];
384            if j >= num_nodes {
385                return Err(SparseError::InvalidArgument(format!(
386                    "column index {} out of bounds for {} nodes",
387                    j, num_nodes
388                )));
389            }
390            let alpha = attention_weights[edge_idx];
391            for f in 0..feature_dim {
392                output[i * feature_dim + f] += alpha * node_features[j * feature_dim + f];
393            }
394        }
395    }
396
397    Ok(output)
398}
399
400// ---------------------------------------------------------------------------
401// Graph utilities
402// ---------------------------------------------------------------------------
403
404/// Compute the degree of each node from CSR row offsets.
405///
406/// `degree[i] = adj_row_offsets[i+1] - adj_row_offsets[i]`.
407///
408/// # Arguments
409///
410/// * `adj_row_offsets` -- CSR row pointer (length `num_nodes + 1`).
411/// * `num_nodes` -- Number of nodes.
412pub fn compute_degree_matrix(adj_row_offsets: &[usize], num_nodes: usize) -> Vec<f64> {
413    let mut degrees = Vec::with_capacity(num_nodes);
414    for i in 0..num_nodes {
415        let start = if i < adj_row_offsets.len() {
416            adj_row_offsets[i]
417        } else {
418            0
419        };
420        let end = if i + 1 < adj_row_offsets.len() {
421            adj_row_offsets[i + 1]
422        } else {
423            start
424        };
425        degrees.push((end - start) as f64);
426    }
427    degrees
428}
429
430/// Symmetric normalization of an adjacency matrix: D^{-1/2} A D^{-1/2}.
431///
432/// Given a CSR adjacency matrix with values, returns a new CSR matrix with
433/// normalized values:
434///
435/// ```text
436/// A_hat[i][j] = A[i][j] / sqrt(deg[i] * deg[j])
437/// ```
438///
439/// This is the standard normalization used in GCN (Kipf & Welling, 2017).
440///
441/// # Arguments
442///
443/// * `adj_row_offsets` -- CSR row pointer.
444/// * `adj_col_indices` -- CSR column indices.
445/// * `adj_values` -- CSR values.
446/// * `degrees` -- Degree of each node (precomputed via [`compute_degree_matrix`]).
447///
448/// # Returns
449///
450/// A tuple `(row_offsets, col_indices, values)` for the normalized CSR matrix.
451/// The sparsity pattern is unchanged; only values are modified.
452pub fn symmetric_normalize(
453    adj_row_offsets: &[usize],
454    adj_col_indices: &[usize],
455    adj_values: &[f64],
456    degrees: &[f64],
457) -> (Vec<usize>, Vec<usize>, Vec<f64>) {
458    let num_nodes = if adj_row_offsets.is_empty() {
459        0
460    } else {
461        adj_row_offsets.len() - 1
462    };
463
464    let mut new_values = Vec::with_capacity(adj_values.len());
465
466    for i in 0..num_nodes {
467        let start = adj_row_offsets[i];
468        let end = adj_row_offsets[i + 1];
469
470        let di = degrees[i];
471        let di_inv_sqrt = if di > 0.0 { 1.0 / di.sqrt() } else { 0.0 };
472
473        for edge_idx in start..end {
474            let j = adj_col_indices[edge_idx];
475            let dj = if j < degrees.len() { degrees[j] } else { 0.0 };
476            let dj_inv_sqrt = if dj > 0.0 { 1.0 / dj.sqrt() } else { 0.0 };
477
478            new_values.push(adj_values[edge_idx] * di_inv_sqrt * dj_inv_sqrt);
479        }
480    }
481
482    (
483        adj_row_offsets.to_vec(),
484        adj_col_indices.to_vec(),
485        new_values,
486    )
487}
488
489/// Add self-loops to an adjacency matrix: A' = A + I.
490///
491/// For each node `i`, ensures there is an edge `(i, i)` with value `1.0`.
492/// Existing self-loops are incremented by `1.0`.
493///
494/// # Arguments
495///
496/// * `adj_row_offsets` -- CSR row pointer.
497/// * `adj_col_indices` -- CSR column indices.
498/// * `adj_values` -- CSR values.
499/// * `num_nodes` -- Number of nodes.
500///
501/// # Returns
502///
503/// A tuple `(row_offsets, col_indices, values)` for the new CSR matrix with self-loops.
504pub fn add_self_loops(
505    adj_row_offsets: &[usize],
506    adj_col_indices: &[usize],
507    adj_values: &[f64],
508    num_nodes: usize,
509) -> (Vec<usize>, Vec<usize>, Vec<f64>) {
510    let mut new_row_offsets = Vec::with_capacity(num_nodes + 1);
511    let mut new_col_indices = Vec::new();
512    let mut new_values = Vec::new();
513
514    new_row_offsets.push(0);
515
516    for i in 0..num_nodes {
517        let start = adj_row_offsets[i];
518        let end = adj_row_offsets[i + 1];
519
520        let mut has_self_loop = false;
521        let mut inserted_self = false;
522
523        for edge_idx in start..end {
524            let j = adj_col_indices[edge_idx];
525
526            // Insert self-loop at the correct sorted position
527            if !inserted_self && j >= i {
528                if j == i {
529                    // Existing self-loop: increment value
530                    has_self_loop = true;
531                    new_col_indices.push(i);
532                    new_values.push(adj_values[edge_idx] + 1.0);
533                    inserted_self = true;
534                    continue;
535                }
536                // Insert new self-loop before this entry
537                new_col_indices.push(i);
538                new_values.push(1.0);
539                inserted_self = true;
540            }
541
542            new_col_indices.push(j);
543            new_values.push(adj_values[edge_idx]);
544        }
545
546        // If self-loop not yet inserted (all neighbors have index < i, or no neighbors)
547        if !has_self_loop && !inserted_self {
548            new_col_indices.push(i);
549            new_values.push(1.0);
550        }
551
552        new_row_offsets.push(new_col_indices.len());
553    }
554
555    (new_row_offsets, new_col_indices, new_values)
556}
557
558// ---------------------------------------------------------------------------
559// Sparse softmax
560// ---------------------------------------------------------------------------
561
562/// Row-wise softmax over sparse matrix values.
563///
564/// For each row defined by `row_offsets`, computes:
565///
566/// ```text
567/// softmax(v_i) = exp(v_i - max_row) / sum_j exp(v_j - max_row)
568/// ```
569///
570/// Uses the numerically stable log-sum-exp trick.
571///
572/// # Arguments
573///
574/// * `row_offsets` -- CSR row pointer.
575/// * `values` -- Sparse matrix values (modified in place conceptually; returns new vector).
576///
577/// # Returns
578///
579/// A new vector of softmax-normalized values with the same sparsity pattern.
580pub fn sparse_row_softmax(row_offsets: &[usize], values: &[f64]) -> Vec<f64> {
581    if row_offsets.len() <= 1 {
582        return values.to_vec();
583    }
584
585    let num_rows = row_offsets.len() - 1;
586    let mut result = vec![0.0_f64; values.len()];
587
588    for i in 0..num_rows {
589        let start = row_offsets[i];
590        let end = row_offsets[i + 1];
591
592        if start >= end {
593            continue;
594        }
595
596        // Find row maximum for numerical stability
597        let mut max_val = f64::NEG_INFINITY;
598        for v in &values[start..end] {
599            if *v > max_val {
600                max_val = *v;
601            }
602        }
603
604        // Compute exp(v - max) and sum
605        let mut sum = 0.0_f64;
606        for (r, v) in result[start..end].iter_mut().zip(&values[start..end]) {
607            let e = (*v - max_val).exp();
608            *r = e;
609            sum += e;
610        }
611
612        // Normalize
613        if sum > 0.0 {
614            let inv_sum = 1.0 / sum;
615            for r in &mut result[start..end] {
616                *r *= inv_sum;
617            }
618        }
619    }
620
621    result
622}
623
624// ---------------------------------------------------------------------------
625// PTX generation
626// ---------------------------------------------------------------------------
627
628/// Generate a PTX kernel for GPU-accelerated GNN message passing.
629///
630/// Produces a kernel that performs sparse message passing on GPU, where each
631/// thread block handles one or more nodes and iterates over their adjacency
632/// lists to aggregate neighbor features.
633///
634/// The generated kernel name is `gnn_message_passing_f64`.
635///
636/// # Arguments
637///
638/// * `config` -- GNN sparse configuration.
639///
640/// # Errors
641///
642/// Returns [`SparseError::PtxGeneration`] if kernel generation fails.
643pub fn generate_message_passing_ptx(config: &GnnSparseConfig) -> Result<String, SparseError> {
644    let op_name = match config.op {
645        MessagePassingOp::Sum => "sum",
646        MessagePassingOp::Mean => "mean",
647        MessagePassingOp::Max => "max",
648        MessagePassingOp::Min => "min",
649    };
650    let normalize_flag = if config.normalize || config.op == MessagePassingOp::Mean {
651        1
652    } else {
653        0
654    };
655    let feature_dim = config.feature_dim;
656    let num_nodes = config.num_nodes;
657
658    if feature_dim == 0 {
659        return Err(SparseError::PtxGeneration(
660            "feature_dim must be > 0".to_string(),
661        ));
662    }
663
664    // Generate PTX source for message passing kernel
665    let ptx = format!(
666        r#"//
667// GNN Message Passing Kernel ({op_name})
668// Generated by oxicuda-sparse tensor module
669// num_nodes={num_nodes}, feature_dim={feature_dim}, normalize={normalize_flag}
670//
671.version 7.0
672.target sm_70
673.address_size 64
674
675.visible .entry gnn_message_passing_f64(
676    .param .u64 row_offsets_ptr,
677    .param .u64 col_indices_ptr,
678    .param .u64 node_features_ptr,
679    .param .u64 output_ptr,
680    .param .u32 num_nodes_param,
681    .param .u32 feature_dim_param
682)
683{{
684    .reg .u32 %r<32>;
685    .reg .u64 %rd<32>;
686    .reg .f64 %fd<16>;
687    .reg .pred %p<8>;
688
689    // tid = blockIdx.x * blockDim.x + threadIdx.x
690    mov.u32 %r0, %ctaid.x;
691    mov.u32 %r1, %ntid.x;
692    mov.u32 %r2, %tid.x;
693    mad.lo.u32 %r3, %r0, %r1, %r2;
694
695    // Each thread handles one node
696    ld.param.u32 %r4, [num_nodes_param];
697    setp.ge.u32 %p0, %r3, %r4;
698    @%p0 ret;
699
700    // Load row_offsets[node] and row_offsets[node+1]
701    ld.param.u64 %rd0, [row_offsets_ptr];
702    cvt.u64.u32 %rd1, %r3;
703
704    // offset = node * 8 (u64 = 8 bytes)
705    shl.b64 %rd2, %rd1, 3;
706    add.u64 %rd3, %rd0, %rd2;
707    ld.global.u64 %rd4, [%rd3];      // row_start
708    ld.global.u64 %rd5, [%rd3 + 8];  // row_end
709
710    // degree = row_end - row_start
711    sub.u64 %rd6, %rd5, %rd4;
712
713    // Skip if degree == 0
714    setp.eq.u64 %p1, %rd6, 0;
715    @%p1 ret;
716
717    // Iterate over features and neighbors
718    // (simplified: actual implementation would loop over feature_dim
719    //  and accumulate per-feature aggregates)
720
721    ret;
722}}
723"#
724    );
725
726    Ok(ptx)
727}
728
729// ---------------------------------------------------------------------------
730// Tests
731// ---------------------------------------------------------------------------
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    // -- scatter_reduce -------------------------------------------------------
738
739    #[test]
740    fn scatter_reduce_sum() {
741        let src = vec![1.0, 2.0, 3.0, 4.0];
742        let index = vec![0, 1, 0, 1];
743        let result = scatter_reduce(&src, &index, 2, MessagePassingOp::Sum)
744            .expect("scatter_reduce Sum failed");
745        assert!((result[0] - 4.0).abs() < 1e-12);
746        assert!((result[1] - 6.0).abs() < 1e-12);
747    }
748
749    #[test]
750    fn scatter_reduce_mean() {
751        let src = vec![1.0, 2.0, 3.0, 4.0];
752        let index = vec![0, 1, 0, 1];
753        let result = scatter_reduce(&src, &index, 2, MessagePassingOp::Mean)
754            .expect("scatter_reduce Mean failed");
755        assert!((result[0] - 2.0).abs() < 1e-12);
756        assert!((result[1] - 3.0).abs() < 1e-12);
757    }
758
759    #[test]
760    fn scatter_reduce_max() {
761        let src = vec![1.0, 5.0, 3.0, 2.0];
762        let index = vec![0, 1, 0, 1];
763        let result = scatter_reduce(&src, &index, 2, MessagePassingOp::Max)
764            .expect("scatter_reduce Max failed");
765        assert!((result[0] - 3.0).abs() < 1e-12);
766        assert!((result[1] - 5.0).abs() < 1e-12);
767    }
768
769    #[test]
770    fn scatter_reduce_min() {
771        let src = vec![1.0, 5.0, 3.0, 2.0];
772        let index = vec![0, 1, 0, 1];
773        let result = scatter_reduce(&src, &index, 2, MessagePassingOp::Min)
774            .expect("scatter_reduce Min failed");
775        assert!((result[0] - 1.0).abs() < 1e-12);
776        assert!((result[1] - 2.0).abs() < 1e-12);
777    }
778
779    // -- gather ---------------------------------------------------------------
780
781    #[test]
782    fn gather_basic() {
783        let src = vec![10.0, 20.0, 30.0, 40.0];
784        let index = vec![3, 0, 2, 1, 0];
785        let result = gather(&src, &index).expect("gather failed");
786        assert_eq!(result, vec![40.0, 10.0, 30.0, 20.0, 10.0]);
787    }
788
789    // -- message passing (triangle graph) ------------------------------------
790
791    /// Triangle graph: 0--1--2--0 (undirected => 6 directed edges).
792    fn triangle_csr() -> (Vec<usize>, Vec<usize>) {
793        // Node 0: neighbors 1, 2
794        // Node 1: neighbors 0, 2
795        // Node 2: neighbors 0, 1
796        let row_offsets = vec![0, 2, 4, 6];
797        let col_indices = vec![1, 2, 0, 2, 0, 1];
798        (row_offsets, col_indices)
799    }
800
801    #[test]
802    fn message_passing_sum_triangle() {
803        let (row_offsets, col_indices) = triangle_csr();
804        let features = vec![1.0, 2.0, 3.0]; // 1D features
805        let config = GnnSparseConfig {
806            num_nodes: 3,
807            feature_dim: 1,
808            num_edges: 6,
809            op: MessagePassingOp::Sum,
810            normalize: false,
811        };
812        let result = sparse_message_passing(&row_offsets, &col_indices, &features, 1, &config)
813            .expect("message_passing failed");
814        // node 0: sum(features[1], features[2]) = 2 + 3 = 5
815        // node 1: sum(features[0], features[2]) = 1 + 3 = 4
816        // node 2: sum(features[0], features[1]) = 1 + 2 = 3
817        assert!((result[0] - 5.0).abs() < 1e-12);
818        assert!((result[1] - 4.0).abs() < 1e-12);
819        assert!((result[2] - 3.0).abs() < 1e-12);
820    }
821
822    // -- attention-weighted message passing -----------------------------------
823
824    #[test]
825    fn attention_message_triangle() {
826        let (row_offsets, col_indices) = triangle_csr();
827        let features = vec![1.0, 2.0, 3.0];
828        let attention = vec![0.5, 0.5, 0.3, 0.7, 0.6, 0.4];
829        let result = sparse_attention_message(&row_offsets, &col_indices, &features, &attention, 1)
830            .expect("attention message failed");
831        // node 0: 0.5*features[1] + 0.5*features[2] = 0.5*2 + 0.5*3 = 2.5
832        // node 1: 0.3*features[0] + 0.7*features[2] = 0.3*1 + 0.7*3 = 2.4
833        // node 2: 0.6*features[0] + 0.4*features[1] = 0.6*1 + 0.4*2 = 1.4
834        assert!((result[0] - 2.5).abs() < 1e-12);
835        assert!((result[1] - 2.4).abs() < 1e-12);
836        assert!((result[2] - 1.4).abs() < 1e-12);
837    }
838
839    // -- degree matrix --------------------------------------------------------
840
841    #[test]
842    fn degree_matrix_triangle() {
843        let (row_offsets, _col_indices) = triangle_csr();
844        let degrees = compute_degree_matrix(&row_offsets, 3);
845        assert!((degrees[0] - 2.0).abs() < 1e-12);
846        assert!((degrees[1] - 2.0).abs() < 1e-12);
847        assert!((degrees[2] - 2.0).abs() < 1e-12);
848    }
849
850    // -- symmetric normalization ----------------------------------------------
851
852    #[test]
853    fn symmetric_normalize_triangle() {
854        let (row_offsets, col_indices) = triangle_csr();
855        let values = vec![1.0; 6];
856        let degrees = compute_degree_matrix(&row_offsets, 3);
857        let (new_rp, new_ci, new_vals) =
858            symmetric_normalize(&row_offsets, &col_indices, &values, &degrees);
859
860        // Structure unchanged
861        assert_eq!(new_rp, row_offsets);
862        assert_eq!(new_ci, col_indices);
863
864        // For a regular graph (all degrees = 2):
865        // D^-1/2 A D^-1/2 => each entry = 1.0 / sqrt(2*2) = 0.5
866        for v in &new_vals {
867            assert!((v - 0.5).abs() < 1e-12);
868        }
869    }
870
871    // -- add self-loops -------------------------------------------------------
872
873    #[test]
874    fn add_self_loops_triangle() {
875        let (row_offsets, col_indices) = triangle_csr();
876        let values = vec![1.0; 6];
877        let (new_rp, new_ci, new_vals) = add_self_loops(&row_offsets, &col_indices, &values, 3);
878
879        // Each row gains one self-loop: 6 + 3 = 9 edges
880        assert_eq!(new_rp.len(), 4); // 3 nodes + 1
881        assert_eq!(new_ci.len(), 9);
882        assert_eq!(new_vals.len(), 9);
883
884        // Verify self-loops exist for each node
885        for node in 0..3 {
886            let start = new_rp[node];
887            let end = new_rp[node + 1];
888            let row_cols = &new_ci[start..end];
889            assert!(row_cols.contains(&node), "node {} missing self-loop", node);
890        }
891    }
892
893    // -- sparse row softmax ---------------------------------------------------
894
895    #[test]
896    fn sparse_softmax_sums_to_one() {
897        let row_offsets = vec![0, 3, 5];
898        let values = vec![1.0, 2.0, 3.0, 0.5, 1.5];
899        let result = sparse_row_softmax(&row_offsets, &values);
900
901        // Row 0: sum should be 1.0
902        let row0_sum: f64 = result[0..3].iter().sum();
903        assert!((row0_sum - 1.0).abs() < 1e-12);
904
905        // Row 1: sum should be 1.0
906        let row1_sum: f64 = result[3..5].iter().sum();
907        assert!((row1_sum - 1.0).abs() < 1e-12);
908
909        // All values should be positive
910        for v in &result {
911            assert!(*v >= 0.0);
912        }
913    }
914
915    // -- empty graph ----------------------------------------------------------
916
917    #[test]
918    fn empty_graph_message_passing() {
919        let row_offsets = vec![0];
920        let col_indices: Vec<usize> = vec![];
921        let features: Vec<f64> = vec![];
922        let config = GnnSparseConfig {
923            num_nodes: 0,
924            feature_dim: 1,
925            num_edges: 0,
926            op: MessagePassingOp::Sum,
927            normalize: false,
928        };
929        let result = sparse_message_passing(&row_offsets, &col_indices, &features, 1, &config)
930            .expect("empty graph failed");
931        assert!(result.is_empty());
932    }
933
934    // -- single node graph ----------------------------------------------------
935
936    #[test]
937    fn single_node_no_edges() {
938        let row_offsets = vec![0, 0];
939        let col_indices: Vec<usize> = vec![];
940        let features = vec![42.0];
941        let config = GnnSparseConfig {
942            num_nodes: 1,
943            feature_dim: 1,
944            num_edges: 0,
945            op: MessagePassingOp::Sum,
946            normalize: false,
947        };
948        let result = sparse_message_passing(&row_offsets, &col_indices, &features, 1, &config)
949            .expect("single node failed");
950        // No neighbors => output is 0
951        assert!((result[0] - 0.0).abs() < 1e-12);
952    }
953
954    // -- disconnected graph ---------------------------------------------------
955
956    #[test]
957    fn disconnected_graph() {
958        // 4 nodes, only 0->1 and 1->0 edges (nodes 2 and 3 isolated)
959        let row_offsets = vec![0, 1, 2, 2, 2];
960        let col_indices = vec![1, 0];
961        let features = vec![1.0, 2.0, 3.0, 4.0];
962        let config = GnnSparseConfig {
963            num_nodes: 4,
964            feature_dim: 1,
965            num_edges: 2,
966            op: MessagePassingOp::Sum,
967            normalize: false,
968        };
969        let result = sparse_message_passing(&row_offsets, &col_indices, &features, 1, &config)
970            .expect("disconnected graph failed");
971        assert!((result[0] - 2.0).abs() < 1e-12); // node 0 gets features[1] = 2
972        assert!((result[1] - 1.0).abs() < 1e-12); // node 1 gets features[0] = 1
973        assert!((result[2] - 0.0).abs() < 1e-12); // isolated
974        assert!((result[3] - 0.0).abs() < 1e-12); // isolated
975    }
976
977    // -- multi-dimensional features -------------------------------------------
978
979    #[test]
980    fn feature_dim_greater_than_one() {
981        // 2 nodes, 0->1, 1->0, feature_dim=3
982        let row_offsets = vec![0, 1, 2];
983        let col_indices = vec![1, 0];
984        let features = vec![
985            1.0, 2.0, 3.0, // node 0
986            4.0, 5.0, 6.0, // node 1
987        ];
988        let config = GnnSparseConfig {
989            num_nodes: 2,
990            feature_dim: 3,
991            num_edges: 2,
992            op: MessagePassingOp::Sum,
993            normalize: false,
994        };
995        let result = sparse_message_passing(&row_offsets, &col_indices, &features, 3, &config)
996            .expect("multi-dim features failed");
997        // node 0 receives features[1] = [4, 5, 6]
998        assert!((result[0] - 4.0).abs() < 1e-12);
999        assert!((result[1] - 5.0).abs() < 1e-12);
1000        assert!((result[2] - 6.0).abs() < 1e-12);
1001        // node 1 receives features[0] = [1, 2, 3]
1002        assert!((result[3] - 1.0).abs() < 1e-12);
1003        assert!((result[4] - 2.0).abs() < 1e-12);
1004        assert!((result[5] - 3.0).abs() < 1e-12);
1005    }
1006
1007    // -- normalize flag -------------------------------------------------------
1008
1009    #[test]
1010    fn normalize_flag_divides_by_degree() {
1011        let (row_offsets, col_indices) = triangle_csr();
1012        let features = vec![2.0, 4.0, 6.0];
1013        let config = GnnSparseConfig {
1014            num_nodes: 3,
1015            feature_dim: 1,
1016            num_edges: 6,
1017            op: MessagePassingOp::Sum,
1018            normalize: true,
1019        };
1020        let result = sparse_message_passing(&row_offsets, &col_indices, &features, 1, &config)
1021            .expect("normalize failed");
1022        // node 0: (4+6)/2 = 5.0
1023        // node 1: (2+6)/2 = 4.0
1024        // node 2: (2+4)/2 = 3.0
1025        assert!((result[0] - 5.0).abs() < 1e-12);
1026        assert!((result[1] - 4.0).abs() < 1e-12);
1027        assert!((result[2] - 3.0).abs() < 1e-12);
1028    }
1029
1030    // -- PTX generation smoke test --------------------------------------------
1031
1032    #[test]
1033    fn ptx_generation_smoke_test() {
1034        let config = GnnSparseConfig {
1035            num_nodes: 1024,
1036            feature_dim: 64,
1037            num_edges: 8192,
1038            op: MessagePassingOp::Sum,
1039            normalize: true,
1040        };
1041        let ptx = generate_message_passing_ptx(&config).expect("PTX generation failed");
1042        assert!(ptx.contains("gnn_message_passing_f64"));
1043        assert!(ptx.contains(".version 7.0"));
1044        assert!(ptx.contains(".target sm_70"));
1045        assert!(ptx.contains("num_nodes"));
1046    }
1047
1048    #[test]
1049    fn ptx_generation_all_ops() {
1050        for op in &[
1051            MessagePassingOp::Sum,
1052            MessagePassingOp::Mean,
1053            MessagePassingOp::Max,
1054            MessagePassingOp::Min,
1055        ] {
1056            let config = GnnSparseConfig {
1057                num_nodes: 256,
1058                feature_dim: 32,
1059                num_edges: 1024,
1060                op: *op,
1061                normalize: false,
1062            };
1063            let ptx = generate_message_passing_ptx(&config).expect("PTX generation failed");
1064            assert!(!ptx.is_empty());
1065        }
1066    }
1067}