Skip to main content

sc_neurocore_engine/
graph.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Stochastic Graph Layer
8
9//! # Stochastic Graph Layer
10//!
11//! Graph message-passing layer with both rate-mode and SC-mode forward paths.
12//! Supports dense O(n²) and sparse CSR O(nnz) adjacency storage.
13
14use rand::{RngExt, SeedableRng};
15use rand_chacha::ChaCha8Rng;
16use rayon::prelude::*;
17
18/// Compressed Sparse Row storage for adjacency matrices.
19#[derive(Clone, Debug)]
20pub struct CsrMatrix {
21    /// Length n_rows + 1. row_offsets[i]..row_offsets[i+1] indexes into col_indices/values.
22    pub row_offsets: Vec<usize>,
23    pub col_indices: Vec<usize>,
24    pub values: Vec<f64>,
25    pub n_rows: usize,
26    pub n_cols: usize,
27}
28
29impl CsrMatrix {
30    pub fn new(
31        row_offsets: Vec<usize>,
32        col_indices: Vec<usize>,
33        values: Vec<f64>,
34        n_rows: usize,
35        n_cols: usize,
36    ) -> Result<Self, String> {
37        if row_offsets.len() != n_rows + 1 {
38            return Err(format!(
39                "row_offsets length {} != n_rows + 1 = {}",
40                row_offsets.len(),
41                n_rows + 1
42            ));
43        }
44        if col_indices.len() != values.len() {
45            return Err(format!(
46                "col_indices length {} != values length {}",
47                col_indices.len(),
48                values.len()
49            ));
50        }
51        let nnz = *row_offsets.last().ok_or("row_offsets must not be empty")?;
52        if col_indices.len() != nnz {
53            return Err(format!(
54                "col_indices length {} != nnz from row_offsets {}",
55                col_indices.len(),
56                nnz
57            ));
58        }
59        // Row offsets must start at 0 and be non-decreasing, so every row slice
60        // row_offsets[i]..row_offsets[i + 1] is a valid, in-bounds range of the value array.
61        if row_offsets[0] != 0 {
62            return Err(format!(
63                "row_offsets must start at 0, got {}",
64                row_offsets[0]
65            ));
66        }
67        if let Some(pair) = row_offsets.windows(2).find(|w| w[0] > w[1]) {
68            return Err(format!(
69                "row_offsets must be non-decreasing, got {} then {}",
70                pair[0], pair[1]
71            ));
72        }
73        // Every column index must be in range so consumers (e.g. to_dense) cannot index
74        // out of bounds. Without this a malformed CSR was accepted and panicked on use.
75        if let Some(&col) = col_indices.iter().find(|&&c| c >= n_cols) {
76            return Err(format!("col_index {col} out of range for n_cols {n_cols}"));
77        }
78        Ok(Self {
79            row_offsets,
80            col_indices,
81            values,
82            n_rows,
83            n_cols,
84        })
85    }
86
87    pub fn nnz(&self) -> usize {
88        self.values.len()
89    }
90
91    /// Convert dense row-major matrix to CSR, keeping entries with |value| > threshold.
92    pub fn from_dense(dense: &[f64], n_rows: usize, n_cols: usize, threshold: f64) -> Self {
93        let mut row_offsets = Vec::with_capacity(n_rows + 1);
94        let mut col_indices = Vec::new();
95        let mut values = Vec::new();
96
97        row_offsets.push(0);
98        for i in 0..n_rows {
99            for j in 0..n_cols {
100                let v = dense[i * n_cols + j];
101                if v.abs() > threshold {
102                    col_indices.push(j);
103                    values.push(v);
104                }
105            }
106            row_offsets.push(col_indices.len());
107        }
108
109        Self {
110            row_offsets,
111            col_indices,
112            values,
113            n_rows,
114            n_cols,
115        }
116    }
117
118    /// Expand back to dense row-major layout.
119    pub fn to_dense(&self) -> Vec<f64> {
120        let mut dense = vec![0.0_f64; self.n_rows * self.n_cols];
121        for i in 0..self.n_rows {
122            for idx in self.row_offsets[i]..self.row_offsets[i + 1] {
123                dense[i * self.n_cols + self.col_indices[idx]] = self.values[idx];
124            }
125        }
126        dense
127    }
128
129    /// Row-sum (degree) for row i.
130    fn row_sum(&self, i: usize) -> f64 {
131        let mut s = 0.0_f64;
132        for idx in self.row_offsets[i]..self.row_offsets[i + 1] {
133            s += self.values[idx];
134        }
135        s
136    }
137}
138
139/// Adjacency storage: dense or sparse CSR.
140pub enum AdjStorage {
141    Dense { adj: Vec<f64> },
142    Sparse { csr: CsrMatrix },
143}
144
145pub struct StochasticGraphLayer {
146    pub n_nodes: usize,
147    pub n_features: usize,
148    pub storage: AdjStorage,
149    pub weights: Vec<f64>,
150    pub degrees: Vec<f64>,
151}
152
153fn random_weights(n_features: usize, seed: u64) -> Vec<f64> {
154    let mut rng = ChaCha8Rng::seed_from_u64(seed);
155    let mut weights = vec![0.0_f64; n_features * n_features];
156    for w in &mut weights {
157        *w = rng.random::<f64>();
158    }
159    weights
160}
161
162fn dense_degrees(adj: &[f64], n: usize) -> Vec<f64> {
163    let mut degrees = vec![0.0_f64; n];
164    for i in 0..n {
165        let mut sum = 0.0_f64;
166        for j in 0..n {
167            sum += adj[i * n + j];
168        }
169        degrees[i] = sum;
170    }
171    degrees
172}
173
174fn csr_degrees(csr: &CsrMatrix) -> Vec<f64> {
175    (0..csr.n_rows).map(|i| csr.row_sum(i)).collect()
176}
177
178impl StochasticGraphLayer {
179    /// Construct from dense adjacency matrix (backwards-compatible API).
180    pub fn new(adj_flat: Vec<f64>, n_nodes: usize, n_features: usize, seed: u64) -> Self {
181        assert_eq!(
182            adj_flat.len(),
183            n_nodes * n_nodes,
184            "adj_flat must have length n_nodes * n_nodes",
185        );
186        let degrees = dense_degrees(&adj_flat, n_nodes);
187        Self {
188            n_nodes,
189            n_features,
190            storage: AdjStorage::Dense { adj: adj_flat },
191            weights: random_weights(n_features, seed),
192            degrees,
193        }
194    }
195
196    /// Construct from pre-built CSR adjacency.
197    pub fn new_sparse(csr: CsrMatrix, n_features: usize, seed: u64) -> Result<Self, String> {
198        if csr.n_rows != csr.n_cols {
199            return Err(format!(
200                "CSR must be square, got {}x{}",
201                csr.n_rows, csr.n_cols
202            ));
203        }
204        let n_nodes = csr.n_rows;
205        let degrees = csr_degrees(&csr);
206        Ok(Self {
207            n_nodes,
208            n_features,
209            storage: AdjStorage::Sparse { csr },
210            weights: random_weights(n_features, seed),
211            degrees,
212        })
213    }
214
215    /// Convert dense adjacency to CSR if density < `density_threshold` (default 0.3).
216    pub fn from_dense_auto(
217        adj_flat: Vec<f64>,
218        n_nodes: usize,
219        n_features: usize,
220        seed: u64,
221        density_threshold: f64,
222    ) -> Self {
223        assert_eq!(adj_flat.len(), n_nodes * n_nodes);
224        let total = (n_nodes * n_nodes) as f64;
225        let nnz = adj_flat.iter().filter(|v| v.abs() > 1e-15).count() as f64;
226        let density = nnz / total;
227
228        if density < density_threshold {
229            let csr = CsrMatrix::from_dense(&adj_flat, n_nodes, n_nodes, 1e-15);
230            let degrees = csr_degrees(&csr);
231            Self {
232                n_nodes,
233                n_features,
234                storage: AdjStorage::Sparse { csr },
235                weights: random_weights(n_features, seed),
236                degrees,
237            }
238        } else {
239            Self::new(adj_flat, n_nodes, n_features, seed)
240        }
241    }
242
243    /// True if using sparse CSR storage.
244    pub fn is_sparse(&self) -> bool {
245        matches!(self.storage, AdjStorage::Sparse { .. })
246    }
247
248    fn validate_features(&self, node_features: &[f64]) -> Result<(), String> {
249        if node_features.len() != self.n_nodes * self.n_features {
250            return Err(format!(
251                "node_features length mismatch: got {}, expected {}.",
252                node_features.len(),
253                self.n_nodes * self.n_features
254            ));
255        }
256        Ok(())
257    }
258
259    /// Aggregate + weight transform + tanh, dispatched on storage.
260    fn aggregate_and_transform(&self, agg_flat: &[f64]) -> Vec<f64> {
261        let out_rows: Vec<Vec<f64>> = (0..self.n_nodes)
262            .into_par_iter()
263            .map(|i| {
264                let agg = &agg_flat[i * self.n_features..(i + 1) * self.n_features];
265                let mut out = vec![0.0_f64; self.n_features];
266                for (f_out, out_val) in out.iter_mut().enumerate().take(self.n_features) {
267                    let mut acc = 0.0_f64;
268                    for (g, agg_val) in agg.iter().enumerate().take(self.n_features) {
269                        acc += *agg_val * self.weights[g * self.n_features + f_out];
270                    }
271                    *out_val = acc.tanh();
272                }
273                out
274            })
275            .collect();
276        let mut flat = Vec::with_capacity(self.n_nodes * self.n_features);
277        for row in out_rows {
278            flat.extend(row);
279        }
280        flat
281    }
282
283    /// Rate-mode graph forward pass.
284    pub fn forward(&self, node_features: &[f64]) -> Result<Vec<f64>, String> {
285        self.validate_features(node_features)?;
286
287        let mut agg = vec![0.0_f64; self.n_nodes * self.n_features];
288
289        match &self.storage {
290            AdjStorage::Dense { adj } => {
291                let agg_rows: Vec<Vec<f64>> = (0..self.n_nodes)
292                    .into_par_iter()
293                    .map(|i| {
294                        let mut row = vec![0.0_f64; self.n_features];
295                        for f in 0..self.n_features {
296                            let mut acc = 0.0_f64;
297                            for j in 0..self.n_nodes {
298                                acc += adj[i * self.n_nodes + j]
299                                    * node_features[j * self.n_features + f];
300                            }
301                            row[f] = acc;
302                        }
303                        if self.degrees[i] != 0.0 {
304                            for x in &mut row {
305                                *x /= self.degrees[i];
306                            }
307                        }
308                        row
309                    })
310                    .collect();
311                for (i, row) in agg_rows.into_iter().enumerate() {
312                    agg[i * self.n_features..(i + 1) * self.n_features].copy_from_slice(&row);
313                }
314            }
315            AdjStorage::Sparse { csr } => {
316                let agg_rows: Vec<Vec<f64>> = (0..self.n_nodes)
317                    .into_par_iter()
318                    .map(|i| {
319                        let mut row = vec![0.0_f64; self.n_features];
320                        for idx in csr.row_offsets[i]..csr.row_offsets[i + 1] {
321                            let j = csr.col_indices[idx];
322                            let a_ij = csr.values[idx];
323                            for f in 0..self.n_features {
324                                row[f] += a_ij * node_features[j * self.n_features + f];
325                            }
326                        }
327                        if self.degrees[i] != 0.0 {
328                            for x in &mut row {
329                                *x /= self.degrees[i];
330                            }
331                        }
332                        row
333                    })
334                    .collect();
335                for (i, row) in agg_rows.into_iter().enumerate() {
336                    agg[i * self.n_features..(i + 1) * self.n_features].copy_from_slice(&row);
337                }
338            }
339        }
340
341        Ok(self.aggregate_and_transform(&agg))
342    }
343
344    /// SC-mode forward pass using AND+popcount message passing.
345    ///
346    /// Dense: encodes full adjacency matrix.
347    /// Sparse: encodes only non-zero CSR entries, avoiding O(n^2) dense expansion.
348    pub fn forward_sc(
349        &self,
350        node_features: &[f64],
351        length: usize,
352        seed: u64,
353    ) -> Result<Vec<f64>, String> {
354        self.validate_features(node_features)?;
355        if length == 0 {
356            return Err("length must be > 0 for SC mode.".to_string());
357        }
358
359        let mut rng = ChaCha8Rng::seed_from_u64(seed);
360        let words = length.div_ceil(64);
361
362        let feat_packed = crate::bitstream::encode_matrix_prob_to_packed(
363            node_features,
364            self.n_nodes,
365            self.n_features,
366            length,
367            words,
368            &mut rng,
369        );
370
371        let mut agg = vec![0.0_f64; self.n_nodes * self.n_features];
372
373        match &self.storage {
374            AdjStorage::Dense { adj } => {
375                let adj_packed = crate::bitstream::encode_matrix_prob_to_packed(
376                    adj,
377                    self.n_nodes,
378                    self.n_nodes,
379                    length,
380                    words,
381                    &mut rng,
382                );
383                for i in 0..self.n_nodes {
384                    for f in 0..self.n_features {
385                        let mut pop_total = 0_u64;
386                        for j in 0..self.n_nodes {
387                            let a = &adj_packed[i * self.n_nodes + j];
388                            let b = &feat_packed[j * self.n_features + f];
389                            for w in 0..words {
390                                pop_total += crate::bitstream::swar_popcount_word(a[w] & b[w]);
391                            }
392                        }
393                        agg[i * self.n_features + f] = pop_total as f64 / length as f64;
394                    }
395                }
396            }
397            AdjStorage::Sparse { csr } => {
398                // Encode only non-zero adjacency values (nnz entries, not n^2)
399                let nnz = csr.nnz();
400                let adj_vals_clamped: Vec<f64> =
401                    csr.values.iter().map(|v| v.clamp(0.0, 1.0)).collect();
402                let adj_packed = crate::bitstream::encode_matrix_prob_to_packed(
403                    &adj_vals_clamped,
404                    1,
405                    nnz,
406                    length,
407                    words,
408                    &mut rng,
409                );
410                for i in 0..self.n_nodes {
411                    #[allow(clippy::needless_range_loop)]
412                    for idx in csr.row_offsets[i]..csr.row_offsets[i + 1] {
413                        let j = csr.col_indices[idx];
414                        let a = &adj_packed[idx];
415                        for f in 0..self.n_features {
416                            let b = &feat_packed[j * self.n_features + f];
417                            let mut pop = 0_u64;
418                            for w in 0..words {
419                                pop += crate::bitstream::swar_popcount_word(a[w] & b[w]);
420                            }
421                            agg[i * self.n_features + f] += pop as f64 / length as f64;
422                        }
423                    }
424                }
425            }
426        }
427
428        for i in 0..self.n_nodes {
429            if self.degrees[i] != 0.0 {
430                for f in 0..self.n_features {
431                    agg[i * self.n_features + f] /= self.degrees[i];
432                }
433            }
434        }
435
436        let agg_packed = crate::bitstream::encode_matrix_prob_to_packed(
437            &agg,
438            self.n_nodes,
439            self.n_features,
440            length,
441            words,
442            &mut rng,
443        );
444        let w_clamped: Vec<f64> = self.weights.iter().map(|w| w.clamp(0.0, 1.0)).collect();
445        let w_packed = crate::bitstream::encode_matrix_prob_to_packed(
446            &w_clamped,
447            self.n_features,
448            self.n_features,
449            length,
450            words,
451            &mut rng,
452        );
453
454        let mut out = Vec::with_capacity(self.n_nodes * self.n_features);
455        for i in 0..self.n_nodes {
456            for f_out in 0..self.n_features {
457                let mut pop_total = 0_u64;
458                for g in 0..self.n_features {
459                    let a = &agg_packed[i * self.n_features + g];
460                    let b = &w_packed[g * self.n_features + f_out];
461                    for w in 0..words {
462                        pop_total += crate::bitstream::swar_popcount_word(a[w] & b[w]);
463                    }
464                }
465                out.push((pop_total as f64 / length as f64).tanh());
466            }
467        }
468
469        Ok(out)
470    }
471
472    pub fn get_weights(&self) -> Vec<f64> {
473        self.weights.clone()
474    }
475
476    pub fn set_weights(&mut self, weights: Vec<f64>) -> Result<(), String> {
477        if weights.len() != self.n_features * self.n_features {
478            return Err(format!(
479                "weights length mismatch: got {}, expected {}.",
480                weights.len(),
481                self.n_features * self.n_features
482            ));
483        }
484        self.weights = weights;
485        Ok(())
486    }
487}