Skip to main content

synaptic_wiring/topology/
graph.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! [`SynapticGraph`] — adjacency structure with delay and polarity metadata.
4//!
5//! Extends the CSR sparse representation from `sparse.rs` with per-synapse
6//! axonal delay and polarity information. This is the core "wiring diagram"
7//! that the `SynapticMesh` orchestrator uses for spike propagation.
8
9use serde::de::{Deserializer, Error as DeError};
10use serde::{Deserialize, Serialize};
11
12use crate::error::{MeshError, Result};
13use crate::types::{
14    DelayTicks, NeuronId, Polarity, SynapseDescriptor, invalid_signed_weight_polarity_msg,
15    invalid_weight_magnitude_msg, signed_weight_agrees_with_polarity, weight_magnitude_is_valid,
16};
17
18/// Adjacency structure for a spiking neural network with delay and polarity metadata.
19///
20/// Stores connections in Compressed Sparse Row (CSR) format, augmented with
21/// per-synapse delay and polarity vectors aligned to the same index space.
22///
23/// # Layout
24///
25/// ```text
26/// row_ptr:    [0,  3,  7, 10, ...]     — start index per source neuron
27/// targets:    [5, 12,  0,  1,  3, ...]  — target neuron IDs
28/// weights:    [0.9, -0.15, 0.3, ...]    — effective signed weights
29/// delays:     [2, 5, 1, ...]            — axonal delay in ticks
30/// polarities: [Exc, Inh, Exc, ...]      — Dale's law polarity
31/// ```
32#[derive(Clone, Debug, PartialEq, Serialize)]
33pub struct SynapticGraph {
34    /// Number of neurons in the graph.
35    neuron_count: usize,
36    /// Row pointers: `row_ptr[i]` gives the start index in the edge arrays
37    /// for neuron `i`. Length is `neuron_count + 1`.
38    row_ptr: Vec<usize>,
39    /// Target neuron IDs for each synapse.
40    targets: Vec<NeuronId>,
41    /// Synaptic weights (signed: polarity × |weight|).
42    weights: Vec<f32>,
43    /// Axonal propagation delay per synapse.
44    delays: Vec<DelayTicks>,
45    /// Polarity per synapse.
46    polarities: Vec<Polarity>,
47}
48
49#[derive(Deserialize)]
50struct RawSynapticGraph {
51    neuron_count: usize,
52    row_ptr: Vec<usize>,
53    targets: Vec<NeuronId>,
54    weights: Vec<f32>,
55    delays: Vec<DelayTicks>,
56    polarities: Vec<Polarity>,
57}
58
59impl RawSynapticGraph {
60    /// Re-validates the CSR invariants that [`SynapticGraph::from_descriptors`]
61    /// enforces at construction time, since a derived `Deserialize` would
62    /// accept arbitrary field values (mismatched `row_ptr` length,
63    /// non-monotonic offsets, out-of-range targets) that later panic in
64    /// [`SynapticGraph::outgoing`] or [`SynapticGraph::out_degree`].
65    fn into_graph(self) -> std::result::Result<SynapticGraph, String> {
66        let nnz = validate_row_ptr(&self.row_ptr, self.neuron_count)?;
67        validate_edge_array_lengths(
68            nnz,
69            [
70                ("targets", self.targets.len()),
71                ("weights", self.weights.len()),
72                ("delays", self.delays.len()),
73                ("polarities", self.polarities.len()),
74            ],
75        )?;
76        validate_targets_in_bounds(&self.targets, self.neuron_count)?;
77        validate_weights_finite(&self.weights)?;
78        validate_weights_agree_with_polarities(&self.weights, &self.polarities)?;
79
80        Ok(SynapticGraph {
81            neuron_count: self.neuron_count,
82            row_ptr: self.row_ptr,
83            targets: self.targets,
84            weights: self.weights,
85            delays: self.delays,
86            polarities: self.polarities,
87        })
88    }
89}
90
91/// Checks `row_ptr` has `neuron_count + 1` entries starting at 0 and
92/// non-decreasing, returning the total edge count (its final entry).
93fn validate_row_ptr(row_ptr: &[usize], neuron_count: usize) -> std::result::Result<usize, String> {
94    let expected_len = neuron_count
95        .checked_add(1)
96        .ok_or_else(|| format!("neuron_count {neuron_count} is too large"))?;
97    if row_ptr.len() != expected_len {
98        return Err(format!(
99            "row_ptr length {} does not match neuron_count + 1 ({})",
100            row_ptr.len(),
101            expected_len
102        ));
103    }
104    if row_ptr.first().copied() != Some(0) {
105        return Err("row_ptr must start at 0".to_string());
106    }
107    if !row_ptr.windows(2).all(|w| w[0] <= w[1]) {
108        return Err("row_ptr must be non-decreasing".to_string());
109    }
110    Ok(*row_ptr.last().expect("row_ptr is non-empty"))
111}
112
113/// Checks that every named edge array has exactly `nnz` entries.
114fn validate_edge_array_lengths(
115    nnz: usize,
116    arrays: [(&str, usize); 4],
117) -> std::result::Result<(), String> {
118    for (name, len) in arrays {
119        if len != nnz {
120            return Err(format!(
121                "{name} length {len} does not match row_ptr's final offset {nnz}"
122            ));
123        }
124    }
125    Ok(())
126}
127
128/// Checks that every target neuron id is within `[0, neuron_count)`.
129fn validate_targets_in_bounds(
130    targets: &[NeuronId],
131    neuron_count: usize,
132) -> std::result::Result<(), String> {
133    if targets.iter().any(|&t| t as usize >= neuron_count) {
134        return Err("target neuron id out of bounds".to_string());
135    }
136    Ok(())
137}
138
139/// Checks that every weight is finite, so a non-JSON format (which, unlike
140/// JSON, can represent NaN/Inf) can't smuggle in a value that poisons
141/// current sums in [`crate::mesh::SynapticMesh::propagate`].
142fn validate_weights_finite(weights: &[f32]) -> std::result::Result<(), String> {
143    if weights.iter().any(|w| !w.is_finite()) {
144        return Err("synapse weight must be finite".to_string());
145    }
146    Ok(())
147}
148
149/// Signed CSR weight must agree with the stored polarity. Zero, including
150/// IEEE signed zero, is accepted for both polarities.
151fn validate_weights_agree_with_polarities(
152    weights: &[f32],
153    polarities: &[Polarity],
154) -> std::result::Result<(), String> {
155    for (&weight, &polarity) in weights.iter().zip(polarities.iter()) {
156        if !signed_weight_agrees_with_polarity(weight, polarity) {
157            return Err(invalid_signed_weight_polarity_msg(weight, polarity));
158        }
159    }
160    Ok(())
161}
162
163impl<'de> Deserialize<'de> for SynapticGraph {
164    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
165    where
166        D: Deserializer<'de>,
167    {
168        RawSynapticGraph::deserialize(deserializer)?
169            .into_graph()
170            .map_err(DeError::custom)
171    }
172}
173
174impl SynapticGraph {
175    /// Create an empty graph with `n` neurons and no connections.
176    pub fn new(n: usize) -> Self {
177        Self {
178            neuron_count: n,
179            row_ptr: vec![0; n + 1],
180            targets: Vec::new(),
181            weights: Vec::new(),
182            delays: Vec::new(),
183            polarities: Vec::new(),
184        }
185    }
186
187    /// Build from a list of synapse descriptors.
188    ///
189    /// Descriptors need not be sorted; they will be grouped by source neuron.
190    /// Each descriptor `weight` must be a finite non-negative magnitude
191    /// (IEEE signed zero is accepted). Negative, NaN, and infinite
192    /// magnitudes are rejected — they are not `abs()`-normalized.
193    pub fn from_descriptors(
194        neuron_count: usize,
195        descriptors: &[SynapseDescriptor],
196    ) -> Result<Self> {
197        Self::validate_descriptor_indices(neuron_count, descriptors)?;
198        Self::validate_descriptor_weights(descriptors)?;
199
200        // Count edges per source neuron
201        let mut counts = vec![0usize; neuron_count];
202        for desc in descriptors {
203            counts[desc.source as usize] += 1;
204        }
205
206        // Build row_ptr
207        let mut row_ptr = Vec::with_capacity(neuron_count + 1);
208        row_ptr.push(0);
209        for &c in &counts {
210            row_ptr.push(row_ptr.last().unwrap() + c);
211        }
212
213        let nnz = descriptors.len();
214        let mut targets = vec![0u32; nnz];
215        let mut weights = vec![0.0f32; nnz];
216        let mut delays = vec![0u16; nnz];
217        let mut polarities = vec![Polarity::Excitatory; nnz];
218
219        // Place each descriptor at the right position
220        let mut cursor = counts.clone();
221        cursor.fill(0);
222
223        for desc in descriptors {
224            let src = desc.source as usize;
225            let pos = row_ptr[src] + cursor[src];
226            targets[pos] = desc.target;
227            weights[pos] = desc.effective_weight();
228            delays[pos] = desc.delay;
229            polarities[pos] = desc.polarity;
230            cursor[src] += 1;
231        }
232
233        Ok(Self {
234            neuron_count,
235            row_ptr,
236            targets,
237            weights,
238            delays,
239            polarities,
240        })
241    }
242
243    /// Validate that all descriptor source/target indices are within bounds.
244    fn validate_descriptor_indices(
245        neuron_count: usize,
246        descriptors: &[SynapseDescriptor],
247    ) -> Result<()> {
248        if neuron_count == 0 && !descriptors.is_empty() {
249            return Err(MeshError::InvalidConfig(
250                "descriptors provided for a graph with 0 neurons".into(),
251            ));
252        }
253        for desc in descriptors {
254            if desc.source as usize >= neuron_count {
255                return Err(MeshError::IndexOutOfBounds {
256                    index: desc.source as usize,
257                    max: neuron_count.saturating_sub(1),
258                });
259            }
260            if desc.target as usize >= neuron_count {
261                return Err(MeshError::IndexOutOfBounds {
262                    index: desc.target as usize,
263                    max: neuron_count.saturating_sub(1),
264                });
265            }
266        }
267        Ok(())
268    }
269
270    /// Reject negative, NaN, and infinite descriptor magnitudes. IEEE signed
271    /// zero (`-0.0`) is a valid non-negative magnitude.
272    fn validate_descriptor_weights(descriptors: &[SynapseDescriptor]) -> Result<()> {
273        for desc in descriptors {
274            if !weight_magnitude_is_valid(desc.weight) {
275                return Err(MeshError::InvalidConfig(invalid_weight_magnitude_msg(
276                    desc.weight,
277                )));
278            }
279        }
280        Ok(())
281    }
282
283    /// Number of neurons in the graph.
284    pub fn neuron_count(&self) -> usize {
285        self.neuron_count
286    }
287
288    /// Total number of synapses (non-zero entries).
289    pub fn synapse_count(&self) -> usize {
290        self.targets.len()
291    }
292
293    /// Sparsity: fraction of the N×N matrix that is zero.
294    pub fn sparsity(&self) -> f32 {
295        let total = self.neuron_count * self.neuron_count;
296        if total == 0 {
297            return 1.0;
298        }
299        1.0 - (self.synapse_count() as f32 / total as f32)
300    }
301
302    /// Iterate over all outgoing synapses from `source`.
303    /// Returns `(target, weight, delay, polarity)` tuples.
304    pub fn outgoing(
305        &self,
306        source: usize,
307    ) -> impl Iterator<Item = (NeuronId, f32, DelayTicks, Polarity)> + '_ {
308        let start = self.row_ptr[source];
309        let end = self.row_ptr[source + 1];
310        (start..end).map(move |i| {
311            (
312                self.targets[i],
313                self.weights[i],
314                self.delays[i],
315                self.polarities[i],
316            )
317        })
318    }
319
320    /// Get the out-degree of a neuron.
321    pub fn out_degree(&self, neuron: usize) -> usize {
322        self.row_ptr[neuron + 1] - self.row_ptr[neuron]
323    }
324
325    /// Maximum delay present in the graph.
326    pub fn max_delay(&self) -> DelayTicks {
327        self.delays.iter().copied().max().unwrap_or(0)
328    }
329
330    /// Mean out-degree across all neurons.
331    pub fn mean_degree(&self) -> f32 {
332        if self.neuron_count == 0 {
333            return 0.0;
334        }
335        self.synapse_count() as f32 / self.neuron_count as f32
336    }
337
338    /// Raw access to CSR arrays for GPU upload.
339    pub fn to_gpu_arrays(&self) -> (Vec<u32>, Vec<u32>, Vec<f32>, Vec<u16>) {
340        let row_ptr: Vec<u32> = self.row_ptr.iter().map(|&x| x as u32).collect();
341        let targets: Vec<u32> = self.targets.to_vec();
342        (row_ptr, targets, self.weights.clone(), self.delays.clone())
343    }
344
345    /// Row pointer slice (for external iteration).
346    pub fn row_ptr(&self) -> &[usize] {
347        &self.row_ptr
348    }
349
350    /// Target neuron slice.
351    pub fn targets(&self) -> &[NeuronId] {
352        &self.targets
353    }
354
355    /// Weight slice.
356    pub fn weights(&self) -> &[f32] {
357        &self.weights
358    }
359
360    /// Delay slice.
361    pub fn delays_slice(&self) -> &[DelayTicks] {
362        &self.delays
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn empty_graph_has_no_synapses() {
372        let g = SynapticGraph::new(100);
373        assert_eq!(g.neuron_count(), 100);
374        assert_eq!(g.synapse_count(), 0);
375        assert!((g.sparsity() - 1.0).abs() < 1e-6);
376    }
377
378    #[test]
379    fn from_descriptors_roundtrip() {
380        let descs = vec![
381            SynapseDescriptor {
382                source: 0,
383                target: 1,
384                weight: 0.9,
385                delay: 3,
386                polarity: Polarity::Excitatory,
387            },
388            SynapseDescriptor {
389                source: 0,
390                target: 2,
391                weight: 0.15,
392                delay: 1,
393                polarity: Polarity::Inhibitory,
394            },
395            SynapseDescriptor {
396                source: 1,
397                target: 0,
398                weight: 0.5,
399                delay: 2,
400                polarity: Polarity::Excitatory,
401            },
402        ];
403
404        let graph = SynapticGraph::from_descriptors(3, &descs).unwrap();
405        assert_eq!(graph.neuron_count(), 3);
406        assert_eq!(graph.synapse_count(), 3);
407
408        // Neuron 0 has 2 outgoing
409        assert_eq!(graph.out_degree(0), 2);
410        // Neuron 1 has 1 outgoing
411        assert_eq!(graph.out_degree(1), 1);
412        // Neuron 2 has 0 outgoing
413        assert_eq!(graph.out_degree(2), 0);
414
415        // Check inhibitory weight is negative
416        let edges: Vec<_> = graph.outgoing(0).collect();
417        let inh_edge = edges
418            .iter()
419            .find(|(_, _, _, p)| *p == Polarity::Inhibitory)
420            .unwrap();
421        assert!(inh_edge.1 < 0.0);
422    }
423
424    #[test]
425    fn synaptic_graph_json_roundtrip() {
426        let descs = vec![
427            SynapseDescriptor {
428                source: 0,
429                target: 1,
430                weight: 0.9,
431                delay: 3,
432                polarity: Polarity::Excitatory,
433            },
434            SynapseDescriptor {
435                source: 0,
436                target: 2,
437                weight: 0.15,
438                delay: 1,
439                polarity: Polarity::Inhibitory,
440            },
441            SynapseDescriptor {
442                source: 2,
443                target: 1,
444                weight: 0.4,
445                delay: 5,
446                polarity: Polarity::Excitatory,
447            },
448        ];
449        let graph = SynapticGraph::from_descriptors(3, &descs)
450            .expect("hand-written descriptors must build a graph");
451        let json = serde_json::to_string(&graph).expect("serialize SynapticGraph to JSON");
452        let restored: SynapticGraph =
453            serde_json::from_str(&json).expect("deserialize SynapticGraph from JSON");
454        assert_eq!(restored, graph);
455    }
456
457    #[test]
458    fn max_delay_reports_correctly() {
459        let descs = vec![
460            SynapseDescriptor {
461                source: 0,
462                target: 1,
463                weight: 0.5,
464                delay: 7,
465                polarity: Polarity::Excitatory,
466            },
467            SynapseDescriptor {
468                source: 1,
469                target: 0,
470                weight: 0.5,
471                delay: 3,
472                polarity: Polarity::Excitatory,
473            },
474        ];
475        let graph = SynapticGraph::from_descriptors(2, &descs).unwrap();
476        assert_eq!(graph.max_delay(), 7);
477    }
478
479    #[test]
480    fn deserialize_rejects_row_ptr_length_mismatch() {
481        let json = r#"{"neuron_count":2,"row_ptr":[0,1],"targets":[],"weights":[],"delays":[],"polarities":[]}"#;
482        assert!(serde_json::from_str::<SynapticGraph>(json).is_err());
483    }
484
485    #[test]
486    fn deserialize_rejects_non_monotonic_row_ptr() {
487        let json = r#"{"neuron_count":2,"row_ptr":[0,3,1],"targets":[0,0,0],"weights":[0.1,0.1,0.1],"delays":[1,1,1],"polarities":["Excitatory","Excitatory","Excitatory"]}"#;
488        assert!(serde_json::from_str::<SynapticGraph>(json).is_err());
489    }
490
491    #[test]
492    fn deserialize_rejects_edge_array_length_mismatch() {
493        let json = r#"{"neuron_count":1,"row_ptr":[0,2],"targets":[0],"weights":[0.1,0.1],"delays":[1,1],"polarities":["Excitatory","Excitatory"]}"#;
494        assert!(serde_json::from_str::<SynapticGraph>(json).is_err());
495    }
496
497    #[test]
498    fn validate_weights_finite_rejects_nan_and_infinite() {
499        // JSON has no literal for NaN/Infinity, so this exercises the
500        // validator directly rather than through a JSON round-trip; a
501        // non-JSON serde format (bincode, postcard, cbor) could otherwise
502        // smuggle these values into a deserialized graph.
503        assert!(validate_weights_finite(&[0.1, f32::NAN]).is_err());
504        assert!(validate_weights_finite(&[0.1, f32::INFINITY]).is_err());
505        assert!(validate_weights_finite(&[0.1, f32::NEG_INFINITY]).is_err());
506        assert!(validate_weights_finite(&[0.1, -0.4, 2.0]).is_ok());
507    }
508
509    #[test]
510    fn deserialize_rejects_usize_max_neuron_count_without_overflow_panic() {
511        let json = r#"{"neuron_count":18446744073709551615,"row_ptr":[0,1],"targets":[0],"weights":[0.1],"delays":[1],"polarities":["Excitatory"]}"#;
512        assert!(serde_json::from_str::<SynapticGraph>(json).is_err());
513    }
514
515    #[test]
516    fn deserialize_rejects_out_of_range_target() {
517        let json = r#"{"neuron_count":1,"row_ptr":[0,1],"targets":[5],"weights":[0.1],"delays":[1],"polarities":["Excitatory"]}"#;
518        assert!(serde_json::from_str::<SynapticGraph>(json).is_err());
519    }
520
521    #[test]
522    fn out_of_bounds_source_rejected() {
523        let descs = vec![SynapseDescriptor {
524            source: 5,
525            target: 0,
526            weight: 0.5,
527            delay: 1,
528            polarity: Polarity::Excitatory,
529        }];
530        assert!(SynapticGraph::from_descriptors(3, &descs).is_err());
531    }
532
533    fn descriptor(weight: f32, polarity: Polarity) -> SynapseDescriptor {
534        SynapseDescriptor {
535            source: 0,
536            target: 1,
537            weight,
538            delay: 0,
539            polarity,
540        }
541    }
542
543    #[test]
544    fn from_descriptors_rejects_negative_inhibitory_magnitude() {
545        // Direct Rust construction bypasses serde; the graph loader must not
546        // turn this into an excitatory +0.5 via effective_weight().
547        let invalid = descriptor(-0.5, Polarity::Inhibitory);
548        assert!(SynapticGraph::from_descriptors(2, &[invalid]).is_err());
549    }
550
551    #[test]
552    fn from_descriptors_rejects_nan_and_infinite_magnitudes() {
553        assert!(
554            SynapticGraph::from_descriptors(2, &[descriptor(f32::NAN, Polarity::Excitatory)])
555                .is_err()
556        );
557        assert!(
558            SynapticGraph::from_descriptors(2, &[descriptor(f32::INFINITY, Polarity::Excitatory)])
559                .is_err()
560        );
561        assert!(
562            SynapticGraph::from_descriptors(
563                2,
564                &[descriptor(f32::NEG_INFINITY, Polarity::Inhibitory)]
565            )
566            .is_err()
567        );
568    }
569
570    #[test]
571    fn from_descriptors_accepts_zero_and_positive_magnitudes() {
572        let zero = SynapticGraph::from_descriptors(2, &[descriptor(0.0, Polarity::Inhibitory)]);
573        assert!(zero.is_ok());
574        let signed_zero =
575            SynapticGraph::from_descriptors(2, &[descriptor(-0.0, Polarity::Excitatory)]);
576        assert!(signed_zero.is_ok());
577        let positive = SynapticGraph::from_descriptors(2, &[descriptor(0.5, Polarity::Excitatory)]);
578        assert!(positive.is_ok());
579        let edges: Vec<_> = positive.unwrap().outgoing(0).collect();
580        assert!((edges[0].1 - 0.5).abs() < 1e-6);
581    }
582
583    #[test]
584    fn deserialize_rejects_positive_inhibitory_weight() {
585        let json = r#"{"neuron_count":2,"row_ptr":[0,1,1],"targets":[1],"weights":[0.5],"delays":[0],"polarities":["Inhibitory"]}"#;
586        assert!(serde_json::from_str::<SynapticGraph>(json).is_err());
587    }
588
589    #[test]
590    fn deserialize_rejects_negative_excitatory_weight() {
591        let json = r#"{"neuron_count":2,"row_ptr":[0,1,1],"targets":[1],"weights":[-0.5],"delays":[0],"polarities":["Excitatory"]}"#;
592        assert!(serde_json::from_str::<SynapticGraph>(json).is_err());
593    }
594
595    #[test]
596    fn deserialize_accepts_zero_weight_for_both_polarities() {
597        let inh = r#"{"neuron_count":2,"row_ptr":[0,1,1],"targets":[1],"weights":[0.0],"delays":[0],"polarities":["Inhibitory"]}"#;
598        let exc = r#"{"neuron_count":2,"row_ptr":[0,1,1],"targets":[1],"weights":[0.0],"delays":[0],"polarities":["Excitatory"]}"#;
599        assert!(serde_json::from_str::<SynapticGraph>(inh).is_ok());
600        assert!(serde_json::from_str::<SynapticGraph>(exc).is_ok());
601    }
602
603    #[test]
604    fn validate_weights_agree_with_polarities_rejects_nan_without_json() {
605        // JSON cannot represent NaN/Inf; a binary serde format could.
606        assert!(
607            validate_weights_agree_with_polarities(&[f32::NAN], &[Polarity::Excitatory]).is_err()
608        );
609        assert!(
610            validate_weights_agree_with_polarities(&[f32::INFINITY], &[Polarity::Inhibitory])
611                .is_err()
612        );
613        assert!(
614            validate_weights_agree_with_polarities(&[f32::NEG_INFINITY], &[Polarity::Excitatory])
615                .is_err()
616        );
617        assert!(validate_weights_agree_with_polarities(&[-0.0], &[Polarity::Inhibitory]).is_ok());
618        assert!(validate_weights_agree_with_polarities(&[0.0], &[Polarity::Excitatory]).is_ok());
619    }
620}