Skip to main content

synaptic_wiring/
sparse.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Compressed Sparse Row (CSR) synaptic map for GPU-optimized execution.
4//!
5//! Replaces dense $N \times N$ weight matrices with adjacency lists stored in CSR format,
6//! reducing VRAM pressure and enabling warp-optimized shared memory pulls.
7//!
8//! # Layout
9//!
10//! ```text
11//! row_ptr:    [0, 3, 7, 10, ...]        — start index in col_indices/values per neuron
12//! col_indices:[0, 5, 12, 1, 3, 8, 15, ...] — target neuron indices
13//! values:     [0.9, -0.15, 0.3, ...]     — synaptic weights
14//! ```
15//!
16//! For a 2048-neuron network with ~5% connectivity, this reduces storage from
17//! ~16 MB (dense f32) to ~800 KB (sparse), a 20× reduction.
18
19use serde::{Deserialize, Serialize};
20
21use crate::error::{MeshError, Result};
22
23/// Largest neuron count a `u16` CSR target can address (`0..=u16::MAX`).
24pub const MAX_SPARSE_NEURONS: usize = u16::MAX as usize + 1;
25
26/// A single sparse synaptic connection.
27#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
28pub struct Synapse {
29    /// Target neuron index (column in the weight matrix).
30    pub target: u16,
31    /// Synaptic weight strength.
32    pub weight: f32,
33}
34
35/// Compressed Sparse Row representation of the synaptic weight matrix.
36///
37/// Generic over `N` (number of neurons) to support both small channel routers
38/// and full 2048-neuron routing fabrics.
39///
40/// `N` must be at most [`MAX_SPARSE_NEURONS`] (65,536): target index
41/// `65,535` is representable as `u16`, target `65,536` is not. Constructors
42/// and insertion APIs reject out-of-range or non-`u16` indices rather than
43/// truncating them into a different valid connection.
44#[derive(Clone, Debug, Serialize, Deserialize)]
45pub struct SparseSynapticMap<const N: usize> {
46    /// Row pointers: `row_ptr[i]` gives the start index in `col_indices`/`values`
47    /// for neuron `i`. Length is `N + 1`.
48    pub row_ptr: Vec<usize>,
49    /// Column indices: target neuron for each non-zero entry.
50    pub col_indices: Vec<u16>,
51    /// Non-zero weight values.
52    pub values: Vec<f32>,
53}
54
55impl<const N: usize> Default for SparseSynapticMap<N> {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl<const N: usize> SparseSynapticMap<N> {
62    /// Create an empty sparse synaptic map with `N` neurons.
63    ///
64    /// # Panics
65    ///
66    /// Panics if `N` exceeds [`MAX_SPARSE_NEURONS`]. Prefer
67    /// [`SparseSynapticMap::try_new`] for a recoverable error.
68    pub fn new() -> Self {
69        Self::try_new().unwrap_or_else(|err| panic!("{err}"))
70    }
71
72    /// Fallible constructor that rejects an `N` larger than the `u16`
73    /// target address space.
74    pub fn try_new() -> Result<Self> {
75        validate_neuron_count::<N>()?;
76        Ok(Self {
77            row_ptr: vec![0; N + 1],
78            col_indices: Vec::new(),
79            values: Vec::new(),
80        })
81    }
82
83    /// Build from a dense weight matrix (for migration from dense representations).
84    ///
85    /// # Panics
86    ///
87    /// Panics if `N` exceeds [`MAX_SPARSE_NEURONS`].
88    pub fn from_dense(matrix: &[[f32; N]; N], sparsity_threshold: f32) -> Self {
89        Self::try_from_dense(matrix, sparsity_threshold).unwrap_or_else(|err| panic!("{err}"))
90    }
91
92    /// Fallible counterpart of [`SparseSynapticMap::from_dense`].
93    pub fn try_from_dense(matrix: &[[f32; N]; N], sparsity_threshold: f32) -> Result<Self> {
94        validate_neuron_count::<N>()?;
95        let mut row_ptr = Vec::with_capacity(N + 1);
96        let mut col_indices = Vec::new();
97        let mut values = Vec::new();
98
99        row_ptr.push(0);
100        for row in matrix.iter() {
101            for (col, &w) in row.iter().enumerate() {
102                if w.abs() > sparsity_threshold {
103                    col_indices.push(index_as_u16::<N>(col, "column")?);
104                    values.push(w);
105                }
106            }
107            row_ptr.push(col_indices.len());
108        }
109
110        Ok(Self {
111            row_ptr,
112            col_indices,
113            values,
114        })
115    }
116
117    /// Build from explicit adjacency lists.
118    ///
119    /// # Panics
120    ///
121    /// Panics if `N` is unsupported, `adjacency.len()` is not `N`, or any
122    /// stored target is `>= N`. Prefer [`SparseSynapticMap::try_from_adjacency`].
123    pub fn from_adjacency(adjacency: &[Vec<Synapse>]) -> Self {
124        Self::try_from_adjacency(adjacency).unwrap_or_else(|err| panic!("{err}"))
125    }
126
127    /// Fallible counterpart of [`SparseSynapticMap::from_adjacency`].
128    pub fn try_from_adjacency(adjacency: &[Vec<Synapse>]) -> Result<Self> {
129        validate_neuron_count::<N>()?;
130        if adjacency.len() != N {
131            return Err(MeshError::NeuronCountMismatch {
132                expected: N,
133                got: adjacency.len(),
134                context: "from_adjacency".into(),
135            });
136        }
137
138        let mut row_ptr = Vec::with_capacity(N + 1);
139        let mut col_indices = Vec::new();
140        let mut values = Vec::new();
141
142        row_ptr.push(0);
143        for synapses in adjacency {
144            for syn in synapses {
145                if syn.target as usize >= N {
146                    return Err(MeshError::IndexOutOfBounds {
147                        index: syn.target as usize,
148                        max: N.saturating_sub(1),
149                    });
150                }
151                col_indices.push(syn.target);
152                values.push(syn.weight);
153            }
154            row_ptr.push(col_indices.len());
155        }
156
157        Ok(Self {
158            row_ptr,
159            col_indices,
160            values,
161        })
162    }
163
164    /// Get all synapses for a given neuron (row).
165    pub fn get_row(&self, row: usize) -> impl Iterator<Item = (u16, f32)> + '_ {
166        let start = self.row_ptr[row];
167        let end = self.row_ptr[row + 1];
168        (start..end).map(move |i| (self.col_indices[i], self.values[i]))
169    }
170
171    /// Get the weight from neuron `row` to neuron `col`. Returns 0.0 if not connected.
172    pub fn get_weight(&self, row: usize, col: usize) -> f32 {
173        let start = self.row_ptr[row];
174        let end = self.row_ptr[row + 1];
175        for i in start..end {
176            if self.col_indices[i] as usize == col {
177                return self.values[i];
178            }
179        }
180        0.0
181    }
182
183    /// Update a single synaptic weight. Creates the connection if it doesn't exist
184    /// (when weight exceeds threshold) or removes it (when weight drops below).
185    ///
186    /// # Panics
187    ///
188    /// Panics if `row` or `col` is `>= N`, or if `col` cannot be represented
189    /// as `u16`. Prefer [`SparseSynapticMap::try_set_weight`].
190    pub fn set_weight(&mut self, row: usize, col: usize, weight: f32, sparsity_threshold: f32) {
191        self.try_set_weight(row, col, weight, sparsity_threshold)
192            .unwrap_or_else(|err| panic!("{err}"))
193    }
194
195    /// Fallible counterpart of [`SparseSynapticMap::set_weight`].
196    ///
197    /// On error the map is left unchanged.
198    pub fn try_set_weight(
199        &mut self,
200        row: usize,
201        col: usize,
202        weight: f32,
203        sparsity_threshold: f32,
204    ) -> Result<()> {
205        let _ = index_as_u16::<N>(row, "row")?;
206        let col_u16 = index_as_u16::<N>(col, "column")?;
207        if let Some(i) = self.find_connection(row, col) {
208            self.update_or_remove(i, row, weight, sparsity_threshold);
209            return Ok(());
210        }
211        if weight.abs() > sparsity_threshold {
212            self.insert_connection(row, col_u16, weight);
213        }
214        Ok(())
215    }
216
217    fn find_connection(&self, row: usize, col: usize) -> Option<usize> {
218        let start = self.row_ptr[row];
219        let end = self.row_ptr[row + 1];
220        (start..end).find(|&i| self.col_indices[i] as usize == col)
221    }
222
223    fn update_or_remove(&mut self, index: usize, row: usize, weight: f32, sparsity_threshold: f32) {
224        if weight.abs() > sparsity_threshold {
225            self.values[index] = weight;
226            return;
227        }
228        self.col_indices.remove(index);
229        self.values.remove(index);
230        self.shift_row_ptr(row, -1);
231    }
232
233    fn insert_connection(&mut self, row: usize, col: u16, weight: f32) {
234        let end = self.row_ptr[row + 1];
235        self.col_indices.insert(end, col);
236        self.values.insert(end, weight);
237        self.shift_row_ptr(row, 1);
238    }
239
240    fn shift_row_ptr(&mut self, row: usize, delta: isize) {
241        for ptr in &mut self.row_ptr[(row + 1)..=N] {
242            *ptr = ptr
243                .checked_add_signed(delta)
244                .expect("row_ptr stays in range");
245        }
246    }
247
248    /// Number of non-zero synapses.
249    pub fn nnz(&self) -> usize {
250        self.values.len()
251    }
252
253    /// Sparsity ratio: fraction of zero entries in the full $N \times N$ matrix.
254    ///
255    /// The denominator is computed in `u64` so `N = 65_536` does not overflow
256    /// `usize` on 32-bit targets (`65_536² = 2³²`).
257    pub fn sparsity(&self) -> f32 {
258        let total = (N as u64).saturating_mul(N as u64);
259        if total == 0 {
260            return 1.0;
261        }
262        1.0 - (self.nnz() as f32 / total as f32)
263    }
264
265    /// Convert back to dense matrix (for debugging / interoperability).
266    pub fn to_dense(&self) -> [[f32; N]; N] {
267        let mut matrix = [[0.0; N]; N];
268        for (row, row_data) in matrix.iter_mut().enumerate().take(N) {
269            for (col, w) in self.get_row(row) {
270                row_data[col as usize] = w;
271            }
272        }
273        matrix
274    }
275
276    /// Export as GPU-ready flat arrays for kernel launch.
277    /// Returns (row_ptr, col_indices, values) as owned vectors.
278    ///
279    /// # Panics
280    ///
281    /// Panics if a row-pointer offset exceeds `u32::MAX`. Prefer
282    /// [`SparseSynapticMap::try_to_gpu_arrays`].
283    pub fn to_gpu_arrays(&self) -> (Vec<u32>, Vec<u32>, Vec<f32>) {
284        self.try_to_gpu_arrays()
285            .unwrap_or_else(|err| panic!("{err}"))
286    }
287
288    /// Fallible counterpart of [`SparseSynapticMap::to_gpu_arrays`].
289    pub fn try_to_gpu_arrays(&self) -> Result<(Vec<u32>, Vec<u32>, Vec<f32>)> {
290        let row_ptr = usizes_to_u32(&self.row_ptr)?;
291        let col_indices: Vec<u32> = self.col_indices.iter().map(|&x| u32::from(x)).collect();
292        Ok((row_ptr, col_indices, self.values.clone()))
293    }
294}
295
296fn validate_neuron_count<const N: usize>() -> Result<()> {
297    if N > MAX_SPARSE_NEURONS {
298        return Err(MeshError::InvalidConfig(format!(
299            "SparseSynapticMap neuron count N={N} exceeds the u16 target address space ({MAX_SPARSE_NEURONS})"
300        )));
301    }
302    Ok(())
303}
304
305fn index_as_u16<const N: usize>(index: usize, what: &str) -> Result<u16> {
306    if index >= N {
307        return Err(MeshError::IndexOutOfBounds {
308            index,
309            max: N.saturating_sub(1),
310        });
311    }
312    u16::try_from(index).map_err(|_| {
313        MeshError::InvalidConfig(format!("{what} {index} is not representable as u16"))
314    })
315}
316
317/// Checked `usize → u32` conversion for CSR row pointers.
318///
319/// Exposed for tests so overflow can be checked without allocating a map
320/// with more than `u32::MAX` synapses.
321pub(crate) fn usizes_to_u32(values: &[usize]) -> Result<Vec<u32>> {
322    values
323        .iter()
324        .map(|&offset| {
325            u32::try_from(offset).map_err(|_| {
326                MeshError::InvalidConfig(format!("row pointer offset {offset} exceeds u32::MAX"))
327            })
328        })
329        .collect()
330}
331
332/// Builder for constructing a sparse synaptic map with a fluent API.
333pub struct SparseSynapticMapBuilder<const N: usize> {
334    adjacency: Vec<Vec<Synapse>>,
335    default_weight: f32,
336    sparsity_threshold: f32,
337}
338
339impl<const N: usize> SparseSynapticMapBuilder<N> {
340    /// # Panics
341    ///
342    /// Panics if `N` exceeds [`MAX_SPARSE_NEURONS`].
343    pub fn new() -> Self {
344        Self::try_new().unwrap_or_else(|err| panic!("{err}"))
345    }
346
347    /// Fallible counterpart of [`SparseSynapticMapBuilder::new`].
348    pub fn try_new() -> Result<Self> {
349        validate_neuron_count::<N>()?;
350        Ok(Self {
351            adjacency: vec![Vec::new(); N],
352            default_weight: 0.0,
353            sparsity_threshold: 0.01,
354        })
355    }
356
357    /// Set the default weight for self-connections.
358    pub fn with_self_weight(mut self, weight: f32) -> Self {
359        self.default_weight = weight;
360        self
361    }
362
363    /// Set the sparsity threshold below which connections are pruned.
364    pub fn with_sparsity_threshold(mut self, threshold: f32) -> Self {
365        self.sparsity_threshold = threshold;
366        self
367    }
368
369    /// Add a synaptic connection.
370    ///
371    /// # Panics
372    ///
373    /// Panics if `from` or `to` is `>= N`. Prefer
374    /// [`SparseSynapticMapBuilder::try_connect`].
375    pub fn connect(mut self, from: usize, to: usize, weight: f32) -> Self {
376        self.try_connect(from, to, weight)
377            .unwrap_or_else(|err| panic!("{err}"));
378        self
379    }
380
381    /// Fallible counterpart of [`SparseSynapticMapBuilder::connect`].
382    /// On error the builder is left unchanged.
383    pub fn try_connect(&mut self, from: usize, to: usize, weight: f32) -> Result<()> {
384        let _ = index_as_u16::<N>(from, "source")?;
385        let target = index_as_u16::<N>(to, "target")?;
386        if weight.abs() > self.sparsity_threshold {
387            self.adjacency[from].push(Synapse { target, weight });
388        }
389        Ok(())
390    }
391
392    /// Add self-connections for all neurons with the default weight.
393    pub fn with_self_connections(self) -> Self {
394        let w = self.default_weight;
395        let mut result = self;
396        for i in 0..N {
397            result = result.connect(i, i, w);
398        }
399        result
400    }
401
402    /// Add lateral inhibition between all pairs with the given weight.
403    pub fn with_lateral_inhibition(self, weight: f32) -> Self {
404        let mut result = self;
405        for i in 0..N {
406            for j in 0..N {
407                if i != j {
408                    result = result.connect(i, j, weight);
409                }
410            }
411        }
412        result
413    }
414
415    /// Build the CSR map.
416    pub fn build(self) -> SparseSynapticMap<N> {
417        SparseSynapticMap::from_adjacency(&self.adjacency)
418    }
419}
420
421impl<const N: usize> Default for SparseSynapticMapBuilder<N> {
422    fn default() -> Self {
423        Self::new()
424    }
425}
426
427/// Per-neuron state snapshot for adaptation-aware routing.
428///
429/// Captures the state needed for dynamic routing: per-neuron
430/// adaptation levels, spike counts, and error estimates.
431#[derive(Clone, Debug, Serialize, Deserialize)]
432pub struct NeuronStateSnapshot {
433    /// Per-neuron adaptation state (0.0 = fresh, 1.0 = fully adapted/exhausted).
434    pub adaptation: Vec<f32>,
435    /// Per-neuron spike count from the last routing window.
436    pub spike_counts: Vec<u32>,
437    /// Estimated error per neuron (e.g. quantization error from external calibration).
438    #[serde(alias = "quant_error")]
439    pub error: Vec<f32>,
440    /// Global routing step index.
441    pub step: u64,
442}
443
444impl NeuronStateSnapshot {
445    pub fn new(num_neurons: usize) -> Self {
446        Self {
447            adaptation: vec![0.0; num_neurons],
448            spike_counts: vec![0; num_neurons],
449            error: vec![0.0; num_neurons],
450            step: 0,
451        }
452    }
453
454    /// Get a routing penalty for a neuron based on its adaptation state.
455    /// Higher adaptation → higher penalty → less likely to be selected.
456    pub fn adaptation_penalty(&self, neuron: usize, alpha: f32) -> f32 {
457        alpha * self.adaptation.get(neuron).copied().unwrap_or(0.0)
458    }
459
460    /// Get a routing bonus for a neuron based on low error.
461    /// Lower error → higher bonus → preferred for routing.
462    pub fn error_bonus(&self, neuron: usize, beta: f32) -> f32 {
463        let err = self.error.get(neuron).copied().unwrap_or(0.0);
464        beta * (1.0 - err.min(1.0))
465    }
466}
467
468/// A routing policy equation for scoring neurons.
469///
470/// The policy computes a routing score for each neuron:
471/// `score = α·spikes - β·adaptation - γ·error + δ·base_weight`
472#[derive(Clone, Debug, Serialize, Deserialize)]
473pub struct RoutingPolicy {
474    /// Weight for spike count contribution.
475    pub alpha: f32,
476    /// Weight for adaptation penalty.
477    pub beta: f32,
478    /// Weight for error penalty.
479    pub gamma: f32,
480    /// Weight for base synaptic strength.
481    pub delta: f32,
482    /// Minimum score threshold to activate a neuron.
483    pub threshold: f32,
484    /// Human-readable description of the policy origin.
485    pub description: String,
486}
487
488impl Default for RoutingPolicy {
489    fn default() -> Self {
490        Self {
491            alpha: 1.0,
492            beta: 0.5,
493            gamma: 0.3,
494            delta: 0.8,
495            threshold: 0.1,
496            description: "default policy".to_string(),
497        }
498    }
499}
500
501impl RoutingPolicy {
502    /// Compute the routing score for a single neuron.
503    pub fn score(&self, neuron: usize, snapshot: &NeuronStateSnapshot, base_weight: f32) -> f32 {
504        let spikes = snapshot.spike_counts.get(neuron).copied().unwrap_or(0) as f32;
505        let adapt = snapshot.adaptation.get(neuron).copied().unwrap_or(0.0);
506        let err = snapshot.error.get(neuron).copied().unwrap_or(0.0);
507
508        self.alpha * spikes - self.beta * adapt - self.gamma * err + self.delta * base_weight
509    }
510
511    /// Check if a neuron should be activated based on its score.
512    pub fn should_activate(&self, score: f32) -> bool {
513        score >= self.threshold
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn set_weight_rejects_truncated_u16_column() {
523        let mut map = SparseSynapticMap::<2>::new();
524        assert!(map.try_set_weight(0, 65_536, 1.0, 0.0).is_err());
525        assert_eq!(map.nnz(), 0);
526        assert_eq!(map.get_weight(0, 0), 0.0);
527    }
528
529    #[test]
530    #[should_panic(expected = "out of bounds")]
531    fn set_weight_panics_on_column_equal_to_n() {
532        let mut map = SparseSynapticMap::<2>::new();
533        map.set_weight(0, 2, 1.0, 0.0);
534    }
535
536    #[test]
537    fn set_weight_rejects_column_equal_to_n_without_mutation() {
538        let mut map = SparseSynapticMap::<2>::new();
539        map.try_set_weight(0, 1, 0.5, 0.0).unwrap();
540        assert!(map.try_set_weight(0, 2, 1.0, 0.0).is_err());
541        assert_eq!(map.nnz(), 1);
542        assert!((map.get_weight(0, 1) - 0.5).abs() < 1e-6);
543    }
544
545    #[test]
546    fn set_weight_rejects_invalid_source_row() {
547        let mut map = SparseSynapticMap::<2>::new();
548        assert!(map.try_set_weight(2, 0, 1.0, 0.0).is_err());
549        assert_eq!(map.nnz(), 0);
550    }
551
552    #[test]
553    fn set_weight_insert_update_remove_roundtrip() {
554        let mut map = SparseSynapticMap::<2>::new();
555        map.try_set_weight(0, 1, 1.0, 0.1).unwrap();
556        assert!((map.get_weight(0, 1) - 1.0).abs() < 1e-6);
557        map.try_set_weight(0, 1, 0.5, 0.1).unwrap();
558        assert!((map.get_weight(0, 1) - 0.5).abs() < 1e-6);
559        map.try_set_weight(0, 1, 0.01, 0.1).unwrap();
560        assert_eq!(map.get_weight(0, 1), 0.0);
561        assert_eq!(map.nnz(), 0);
562    }
563
564    #[test]
565    fn from_adjacency_rejects_target_equal_to_n() {
566        let adjacency = vec![
567            vec![Synapse {
568                target: 2,
569                weight: 1.0,
570            }],
571            vec![],
572        ];
573        assert!(SparseSynapticMap::<2>::try_from_adjacency(&adjacency).is_err());
574    }
575
576    #[test]
577    fn builder_rejects_invalid_target() {
578        let mut builder = SparseSynapticMapBuilder::<2>::new();
579        assert!(builder.try_connect(0, 2, 1.0).is_err());
580        assert!(builder.try_connect(0, 65_536, 1.0).is_err());
581        let map = builder.build();
582        assert_eq!(map.nnz(), 0);
583    }
584
585    #[test]
586    fn empty_and_single_neuron_maps() {
587        let mut empty = SparseSynapticMap::<0>::try_new().unwrap();
588        assert_eq!(empty.nnz(), 0);
589        assert!(empty.try_set_weight(0, 0, 1.0, 0.0).is_err());
590
591        let mut single = SparseSynapticMap::<1>::new();
592        single.try_set_weight(0, 0, 0.8, 0.0).unwrap();
593        assert!((single.get_weight(0, 0) - 0.8).abs() < 1e-6);
594        assert!(single.try_set_weight(0, 1, 0.8, 0.0).is_err());
595    }
596
597    #[test]
598    fn highest_representable_target_is_accepted() {
599        // N = 65_536 allocates only the CSR row_ptr (~512 KiB), not an N×N matrix.
600        let mut map = SparseSynapticMap::<65_536>::try_new().unwrap();
601        map.try_set_weight(0, 65_535, 1.0, 0.0).unwrap();
602        assert!((map.get_weight(0, 65_535) - 1.0).abs() < 1e-6);
603        assert!((map.sparsity() - 1.0).abs() < 1e-6);
604        let (_row_ptr, cols, values) = map.try_to_gpu_arrays().unwrap();
605        assert_eq!(cols, vec![65_535]);
606        assert!((values[0] - 1.0).abs() < 1e-6);
607    }
608
609    #[test]
610    fn try_new_rejects_n_above_u16_address_space() {
611        // Allocates only a small row_ptr probe via try_new, not an N×N matrix.
612        assert!(SparseSynapticMap::<65_537>::try_new().is_err());
613        assert!(SparseSynapticMapBuilder::<65_537>::try_new().is_err());
614    }
615
616    #[test]
617    fn usizes_to_u32_accepts_u32_max() {
618        assert_eq!(usizes_to_u32(&[0, u32::MAX as usize]).unwrap().len(), 2);
619    }
620
621    #[cfg(target_pointer_width = "64")]
622    #[test]
623    fn usizes_to_u32_rejects_unrepresentable_offset() {
624        let too_big = (u32::MAX as usize) + 1;
625        assert!(usizes_to_u32(&[0, too_big]).is_err());
626    }
627}