Skip to main content

synaptic_wiring/
types.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Core types for `synaptic-wiring`.
4//!
5//! Shared scalar types, measurement units, and configuration structs used
6//! across the topology, delay, and mesh modules.
7
8use serde::de::{Deserializer, Error as DeError};
9use serde::{Deserialize, Serialize};
10
11// ── Scalar aliases ────────────────────────────────────────────────────────────
12
13/// Unique identifier for a neuron within a mesh.
14pub type NeuronId = u32;
15
16/// Axonal propagation delay in discrete simulation ticks.
17/// A delay of 0 means same-tick delivery (instantaneous).
18pub type DelayTicks = u16;
19
20// ── Polarity ──────────────────────────────────────────────────────────────────
21
22/// Synaptic polarity following Dale's principle: a neuron's outgoing
23/// synapses are either all excitatory or all inhibitory.
24///
25/// # References
26///
27/// Dale, H. H. (1935). *Pharmacology and Nerve-endings.* Proceedings of
28/// the Royal Society of Medicine, 28(3), 319–332.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
30pub enum Polarity {
31    /// Positive synaptic weight → depolarises the postsynaptic neuron.
32    #[default]
33    Excitatory,
34    /// Negative synaptic weight → hyperpolarises the postsynaptic neuron.
35    Inhibitory,
36}
37
38impl Polarity {
39    /// Sign multiplier: +1.0 for excitatory, −1.0 for inhibitory.
40    pub fn sign(self) -> f32 {
41        match self {
42            Self::Excitatory => 1.0,
43            Self::Inhibitory => -1.0,
44        }
45    }
46}
47
48// ── Synapse descriptor ────────────────────────────────────────────────────────
49
50/// A fully-described synaptic connection with weight, delay, and polarity.
51#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
52pub struct SynapseDescriptor {
53    /// Source neuron.
54    pub source: NeuronId,
55    /// Target neuron.
56    pub target: NeuronId,
57    /// Absolute synaptic weight (always ≥ 0; sign comes from polarity).
58    #[serde(deserialize_with = "deserialize_nonnegative_weight")]
59    pub weight: f32,
60    /// Axonal propagation delay in ticks.
61    pub delay: DelayTicks,
62    /// Excitatory or inhibitory.
63    pub polarity: Polarity,
64}
65
66impl SynapseDescriptor {
67    /// Effective signed weight: `weight × polarity.sign()`.
68    ///
69    /// `weight` is a non-negative magnitude. Invalid magnitudes are rejected
70    /// by [`crate::topology::SynapticGraph::from_descriptors`] and by
71    /// descriptor deserialization rather than being silently `abs()`-normalized
72    /// here.
73    ///
74    /// # Panics
75    ///
76    /// Panics if `weight` is negative, NaN, or infinite. Ordinary Rust struct
77    /// literals can still construct those values; this check runs in release
78    /// builds so a negative inhibitory magnitude cannot flip sign.
79    pub fn effective_weight(&self) -> f32 {
80        assert!(
81            weight_magnitude_is_valid(self.weight),
82            "synapse weight must be finite and non-negative, got {}",
83            self.weight
84        );
85        self.weight * self.polarity.sign()
86    }
87}
88
89/// `true` when `weight` is a finite magnitude, including IEEE `+0.0` and `-0.0`.
90///
91/// `-0.0 < 0.0` is false, so signed zero is accepted rather than rejected as
92/// a negative magnitude.
93pub(crate) fn weight_magnitude_is_valid(weight: f32) -> bool {
94    weight.is_finite() && weight >= 0.0
95}
96
97/// Error text for an invalid descriptor magnitude (negative, NaN, or infinite).
98pub(crate) fn invalid_weight_magnitude_msg(weight: f32) -> String {
99    format!("synapse weight must be finite and non-negative, got {weight}")
100}
101
102/// CSR graphs store **signed** weights. Zero (including signed zero) is valid
103/// for both polarities; a strictly positive inhibitory weight or a strictly
104/// negative excitatory weight is not.
105pub(crate) fn signed_weight_agrees_with_polarity(weight: f32, polarity: Polarity) -> bool {
106    if !weight.is_finite() {
107        return false;
108    }
109    match polarity {
110        Polarity::Excitatory => weight >= 0.0,
111        Polarity::Inhibitory => weight <= 0.0,
112    }
113}
114
115/// Error text for a signed CSR weight that disagrees with its polarity.
116pub(crate) fn invalid_signed_weight_polarity_msg(weight: f32, polarity: Polarity) -> String {
117    if !weight.is_finite() {
118        return format!("synapse weight must be finite, got {weight}");
119    }
120    match polarity {
121        Polarity::Excitatory => {
122            format!("excitatory synapse weight must be >= 0 (signed zero allowed), got {weight}")
123        }
124        Polarity::Inhibitory => {
125            format!("inhibitory synapse weight must be <= 0 (signed zero allowed), got {weight}")
126        }
127    }
128}
129
130/// Rejects negative or non-finite weights so deserialized descriptors keep
131/// the documented `weight >= 0` invariant that [`SynapseDescriptor::effective_weight`]
132/// relies on to apply polarity correctly.
133fn deserialize_nonnegative_weight<'de, D>(deserializer: D) -> std::result::Result<f32, D::Error>
134where
135    D: Deserializer<'de>,
136{
137    let weight = f32::deserialize(deserializer)?;
138    if !weight_magnitude_is_valid(weight) {
139        return Err(DeError::custom(invalid_weight_magnitude_msg(weight)));
140    }
141    Ok(weight)
142}
143
144// ── Topology parameters ──────────────────────────────────────────────────────
145
146/// Configuration for network topology generation.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct TopologyConfig {
149    /// Number of neurons in the network.
150    pub neuron_count: usize,
151    /// Fraction of neurons that are inhibitory (Dale's law).
152    /// Typical cortical value: ~0.20 (80% excitatory, 20% inhibitory).
153    pub inhibitory_fraction: f32,
154    /// Maximum axonal delay in ticks.
155    pub max_delay: DelayTicks,
156    /// Minimum absolute weight to retain (sparsity pruning threshold).
157    pub sparsity_threshold: f32,
158}
159
160impl Default for TopologyConfig {
161    fn default() -> Self {
162        Self {
163            neuron_count: 2048,
164            inhibitory_fraction: 0.20,
165            max_delay: 20,
166            sparsity_threshold: 0.01,
167        }
168    }
169}
170
171// ── Connection probability models ─────────────────────────────────────────────
172
173/// Strategy for determining connection probability between neuron pairs.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub enum ConnectionModel {
176    /// Erdős–Rényi: each pair connected independently with probability `p`.
177    Uniform { p: f32 },
178    /// Distance-dependent: probability decays with Euclidean distance.
179    /// `p(d) = p_max × exp(−d / lambda)`.
180    DistanceDependent { p_max: f32, lambda: f32 },
181    /// Watts–Strogatz small-world: each neuron has `k` **outgoing** directed
182    /// synapses to its nearest ring neighbours (`k/2` clockwise, `k/2`
183    /// counterclockwise). `k` must be even. Each outgoing synapse is then
184    /// rewired with probability `beta` to a different non-self target when
185    /// one is available (a dense ring `k = n - 1` has none).
186    SmallWorld { k: usize, beta: f32 },
187    /// Barabási–Albert preferential attachment: start with `m0` nodes that
188    /// are fully connected in both directions (`m0 * (m0 - 1)` directed
189    /// synapses). Each new node attaches to exactly `m` distinct older
190    /// nodes, and each attachment is stored as a reciprocal directed pair.
191    ScaleFree { m0: usize, m: usize },
192    /// Feed-forward layered: neurons arranged in layers, each layer
193    /// fully connected to the next with given probability.
194    Layered {
195        layer_sizes: Vec<usize>,
196        inter_layer_p: f32,
197    },
198}
199
200impl Default for ConnectionModel {
201    fn default() -> Self {
202        Self::Uniform { p: 0.05 }
203    }
204}
205
206// ── Delay model ───────────────────────────────────────────────────────────────
207
208/// Strategy for assigning axonal delays to synapses.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub enum DelayModel {
211    /// All synapses have the same fixed delay.
212    Fixed { delay: DelayTicks },
213    /// Delay is proportional to Euclidean distance between neurons.
214    /// `delay = clamp(round(distance / speed), min_delay, max_delay)`.
215    DistanceProportional {
216        speed: f32,
217        min_delay: DelayTicks,
218        max_delay: DelayTicks,
219    },
220    /// Delay drawn uniformly from `[min_delay, max_delay]`.
221    UniformRandom {
222        min_delay: DelayTicks,
223        max_delay: DelayTicks,
224    },
225}
226
227impl Default for DelayModel {
228    fn default() -> Self {
229        Self::Fixed { delay: 1 }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn deserialize_rejects_negative_weight() {
239        let json = r#"{"source":0,"target":1,"weight":-0.5,"delay":1,"polarity":"Excitatory"}"#;
240        assert!(serde_json::from_str::<SynapseDescriptor>(json).is_err());
241    }
242
243    #[test]
244    fn deserialize_accepts_valid_weight() {
245        let json = r#"{"source":0,"target":1,"weight":0.5,"delay":1,"polarity":"Inhibitory"}"#;
246        let desc: SynapseDescriptor = serde_json::from_str(json).unwrap();
247        assert_eq!(desc.weight, 0.5);
248        assert!((desc.effective_weight() + 0.5).abs() < 1e-6);
249    }
250
251    #[test]
252    fn deserialize_accepts_zero_weight() {
253        let json = r#"{"source":0,"target":1,"weight":0.0,"delay":1,"polarity":"Excitatory"}"#;
254        let desc: SynapseDescriptor = serde_json::from_str(json).unwrap();
255        assert_eq!(desc.weight, 0.0);
256        assert_eq!(desc.effective_weight(), 0.0);
257    }
258
259    #[test]
260    #[should_panic(expected = "synapse weight must be finite and non-negative")]
261    fn effective_weight_panics_on_negative_inhibitory_magnitude() {
262        let desc = SynapseDescriptor {
263            source: 0,
264            target: 1,
265            weight: -0.5,
266            delay: 1,
267            polarity: Polarity::Inhibitory,
268        };
269        let _ = desc.effective_weight();
270    }
271
272    #[test]
273    fn weight_magnitude_accepts_signed_zero_and_rejects_non_finite() {
274        assert!(weight_magnitude_is_valid(0.0));
275        assert!(weight_magnitude_is_valid(-0.0));
276        assert!(weight_magnitude_is_valid(0.5));
277        assert!(!weight_magnitude_is_valid(-0.5));
278        assert!(!weight_magnitude_is_valid(f32::NAN));
279        assert!(!weight_magnitude_is_valid(f32::INFINITY));
280        assert!(!weight_magnitude_is_valid(f32::NEG_INFINITY));
281    }
282
283    #[test]
284    fn signed_weight_polarity_accepts_signed_zero_for_both() {
285        assert!(signed_weight_agrees_with_polarity(
286            0.0,
287            Polarity::Excitatory
288        ));
289        assert!(signed_weight_agrees_with_polarity(
290            -0.0,
291            Polarity::Excitatory
292        ));
293        assert!(signed_weight_agrees_with_polarity(
294            0.0,
295            Polarity::Inhibitory
296        ));
297        assert!(signed_weight_agrees_with_polarity(
298            -0.0,
299            Polarity::Inhibitory
300        ));
301        assert!(signed_weight_agrees_with_polarity(
302            0.5,
303            Polarity::Excitatory
304        ));
305        assert!(signed_weight_agrees_with_polarity(
306            -0.5,
307            Polarity::Inhibitory
308        ));
309        assert!(!signed_weight_agrees_with_polarity(
310            -0.5,
311            Polarity::Excitatory
312        ));
313        assert!(!signed_weight_agrees_with_polarity(
314            0.5,
315            Polarity::Inhibitory
316        ));
317        assert!(!signed_weight_agrees_with_polarity(
318            f32::NAN,
319            Polarity::Excitatory
320        ));
321        assert!(!signed_weight_agrees_with_polarity(
322            f32::INFINITY,
323            Polarity::Inhibitory
324        ));
325        assert!(!signed_weight_agrees_with_polarity(
326            f32::NEG_INFINITY,
327            Polarity::Excitatory
328        ));
329    }
330}