Skip to main content

synaptic_wiring/
mesh.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! [`SynapticMesh`] — top-level orchestrator owning topology + delays.
4//!
5//! The `SynapticMesh` is the primary public-facing struct of this crate.
6//! It owns a [`SynapticGraph`] (the wiring diagram) and a [`SpikeDelayBuffer`]
7//! (the temporal delay infrastructure), and provides a single `propagate()`
8//! method that converts source spikes into delayed synaptic currents.
9//! Callers that already own an output buffer can use `propagate_into` /
10//! `propagate_graded_into` to reuse that storage each tick.
11//!
12//! # Usage
13//!
14//! ```rust
15//! use synaptic_wiring::mesh::SynapticMesh;
16//! use synaptic_wiring::topology::generate_random;
17//!
18//! let graph = generate_random(64, 0.1, 5, 0.2).unwrap();
19//! let mut mesh = SynapticMesh::new(graph);
20//!
21//! // Each tick: provide binary spike vector, receive synaptic currents
22//! let spikes = vec![false; 64];
23//! let _currents = mesh.propagate(&spikes);
24//!
25//! // Fixed-rate loops can reuse a caller-owned buffer instead:
26//! let mut currents = vec![0.0; 64];
27//! mesh.propagate_into(&spikes, &mut currents).unwrap();
28//! ```
29
30use serde::de::{Deserializer, Error as DeError};
31use serde::{Deserialize, Serialize};
32
33use crate::delay::{SpikeDelayBuffer, validate_current_tick};
34use crate::error::{MeshError, Result};
35use crate::topology::{SynapticGraph, TopologyDigest};
36
37/// Top-level synaptic wiring orchestrator.
38///
39/// Owns the network topology (graph with weights, delays, polarities) and
40/// the temporal delay buffer. Converts source spikes into time-delayed
41/// synaptic currents delivered to target neurons.
42///
43/// Deserialization requires the graph and buffer neuron counts to agree,
44/// the buffer capacity to be at least the graph's maximum delay, and
45/// `tick` to equal `delay_buffer.current_tick`. Both ticks must leave
46/// headroom so one `propagate` cannot land on `usize::MAX`. Inconsistent
47/// timestamps are rejected rather than repaired.
48#[derive(Clone, Debug, Serialize)]
49pub struct SynapticMesh {
50    /// The wiring diagram.
51    graph: SynapticGraph,
52    /// Ring-buffer delay queue for spike delivery.
53    delay_buffer: SpikeDelayBuffer,
54    /// Current simulation tick.
55    tick: u64,
56    /// Reused `(target, delay, current)` scratch for graded aggregation.
57    ///
58    /// Not part of checkpoint state: serde omits it, and constructors
59    /// reserve `synapse_count()` so `propagate_graded_into` does not
60    /// allocate on the success path after `new`.
61    #[serde(skip)]
62    pending_scratch: Vec<(usize, usize, f32)>,
63}
64
65#[derive(Deserialize)]
66struct RawSynapticMesh {
67    graph: SynapticGraph,
68    delay_buffer: SpikeDelayBuffer,
69    tick: u64,
70}
71
72impl RawSynapticMesh {
73    fn into_mesh(self) -> std::result::Result<SynapticMesh, String> {
74        if self.graph.neuron_count() != self.delay_buffer.neuron_count() {
75            return Err(format!(
76                "mesh graph neuron_count {} does not match delay buffer neuron_count {}",
77                self.graph.neuron_count(),
78                self.delay_buffer.neuron_count()
79            ));
80        }
81        let graph_max = usize::from(self.graph.max_delay());
82        if self.delay_buffer.max_delay() < graph_max {
83            return Err(format!(
84                "delay buffer max_delay {} is smaller than the graph's maximum delay {graph_max}",
85                self.delay_buffer.max_delay()
86            ));
87        }
88        let max_delay = self.delay_buffer.max_delay();
89        validate_current_tick(self.tick, max_delay)?;
90        validate_current_tick(self.delay_buffer.current_tick(), max_delay)?;
91        if self.tick != self.delay_buffer.current_tick() {
92            return Err(format!(
93                "mesh tick {} does not match delay buffer current_tick {}",
94                self.tick,
95                self.delay_buffer.current_tick()
96            ));
97        }
98        Ok(SynapticMesh::from_parts(
99            self.graph,
100            self.delay_buffer,
101            self.tick,
102        ))
103    }
104}
105
106impl<'de> Deserialize<'de> for SynapticMesh {
107    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
108    where
109        D: Deserializer<'de>,
110    {
111        RawSynapticMesh::deserialize(deserializer)?
112            .into_mesh()
113            .map_err(DeError::custom)
114    }
115}
116
117impl SynapticMesh {
118    /// Create a new mesh from a pre-built graph.
119    ///
120    /// The delay buffer is sized to the graph's maximum delay.
121    pub fn new(graph: SynapticGraph) -> Self {
122        let max_delay = usize::from(graph.max_delay());
123        let n = graph.neuron_count();
124        Self::from_parts(graph, SpikeDelayBuffer::new(n, max_delay), 0)
125    }
126
127    /// Create a mesh with a custom maximum delay (overriding graph's max).
128    ///
129    /// Useful when you want headroom for future dynamic delay changes.
130    ///
131    /// `max_delay` must be at least `graph.max_delay()`. A smaller buffer
132    /// is rejected at construction — before any `propagate` call — rather
133    /// than wrapping an oversized delay onto an earlier tick. Use
134    /// [`SynapticMesh::new`] to size the buffer from the graph automatically,
135    /// or [`SynapticMesh::try_with_max_delay`] for a recoverable error.
136    ///
137    /// # Panics
138    ///
139    /// Panics if `max_delay` is smaller than the graph's maximum delay, or
140    /// if `max_delay + 1` overflows `usize`.
141    pub fn with_max_delay(graph: SynapticGraph, max_delay: usize) -> Self {
142        Self::try_with_max_delay(graph, max_delay).unwrap_or_else(|err| panic!("{err}"))
143    }
144
145    /// Fallible counterpart of [`SynapticMesh::with_max_delay`].
146    ///
147    /// Returns [`MeshError::DelayError`] when `max_delay` cannot hold every
148    /// synapse delay in `graph`, or when the ring depth would overflow.
149    pub fn try_with_max_delay(graph: SynapticGraph, max_delay: usize) -> Result<Self> {
150        let graph_max = usize::from(graph.max_delay());
151        if max_delay < graph_max {
152            return Err(MeshError::DelayError(format!(
153                "max_delay {max_delay} is smaller than the graph's maximum delay {graph_max}"
154            )));
155        }
156        let n = graph.neuron_count();
157        Ok(Self::from_parts(
158            graph,
159            SpikeDelayBuffer::try_new(n, max_delay)?,
160            0,
161        ))
162    }
163
164    fn from_parts(graph: SynapticGraph, delay_buffer: SpikeDelayBuffer, tick: u64) -> Self {
165        let pending_scratch = Vec::with_capacity(graph.synapse_count());
166        Self {
167            graph,
168            delay_buffer,
169            tick,
170            pending_scratch,
171        }
172    }
173
174    /// Propagate spikes through the mesh for one tick.
175    ///
176    /// # Arguments
177    ///
178    /// * `source_spikes` — boolean spike vector: `true` if neuron `i` fired
179    ///   this tick. Length must equal `neuron_count()`.
180    ///
181    /// # Returns
182    ///
183    /// Synaptic current vector of length `neuron_count()` — the total
184    /// incoming current at each neuron for this tick (including delayed
185    /// spikes from previous ticks).
186    ///
187    /// # Delivery contract
188    ///
189    /// 1. A spike from neuron `s` on tick `t` delivers current to every
190    ///    target of `s` on tick `t + delay`. A `delay` of 0 arrives in the
191    ///    return value of this very call. This assumes the delay buffer can
192    ///    hold the graph's delays, which [`SynapticMesh::new`] guarantees.
193    ///    [`with_max_delay`] rejects a buffer smaller than the graph's
194    ///    longest delay at construction (debug and release).
195    /// 2. The magnitude is the synapse's weight and the sign is that
196    ///    synapse's [`Polarity`](crate::types::Polarity), so an inhibitory
197    ///    synapse subtracts. The generators assign polarity per source
198    ///    neuron (Dale's law), but [`SynapticGraph::from_descriptors`] takes
199    ///    each descriptor's polarity as given and does not check that one
200    ///    source's synapses agree.
201    ///
202    /// [`with_max_delay`]: Self::with_max_delay
203    /// [`SynapticGraph::from_descriptors`]: crate::topology::SynapticGraph::from_descriptors
204    /// 3. Currents arriving at the same neuron on the same tick are summed.
205    /// 4. Propagation is **one hop**: received current never becomes a spike
206    ///    by itself. You own the neuron model — threshold the returned
207    ///    currents and feed the result into the next call to build a
208    ///    multi-layer loop.
209    /// 5. Delivery is deterministic: the same graph and the same spike
210    ///    sequence produce the same currents, including after [`reset`].
211    ///
212    /// [`reset`]: Self::reset
213    ///
214    /// ```rust
215    /// use synaptic_wiring::mesh::SynapticMesh;
216    /// use synaptic_wiring::topology::SynapticGraph;
217    /// use synaptic_wiring::types::{Polarity, SynapseDescriptor};
218    ///
219    /// // 0 ──(w=0.75, delay 0, excitatory)──▶ 1
220    /// // 0 ──(w=0.50, delay 2, excitatory)──▶ 2
221    /// let graph = SynapticGraph::from_descriptors(
222    ///     3,
223    ///     &[
224    ///         SynapseDescriptor { source: 0, target: 1, weight: 0.75, delay: 0, polarity: Polarity::Excitatory },
225    ///         SynapseDescriptor { source: 0, target: 2, weight: 0.50, delay: 2, polarity: Polarity::Excitatory },
226    ///     ],
227    /// )?;
228    /// let mut mesh = SynapticMesh::new(graph);
229    ///
230    /// // Tick 0: neuron 0 fires — only the delay-0 synapse lands.
231    /// assert_eq!(mesh.propagate(&[true, false, false])?, vec![0.0, 0.75, 0.0]);
232    /// // Tick 1: nothing in flight arrives yet.
233    /// assert_eq!(mesh.propagate(&[false; 3])?, vec![0.0, 0.0, 0.0]);
234    /// // Tick 2: the delay-2 synapse arrives at neuron 2.
235    /// assert_eq!(mesh.propagate(&[false; 3])?, vec![0.0, 0.0, 0.50]);
236    /// # Ok::<(), synaptic_wiring::MeshError>(())
237    /// ```
238    ///
239    /// `tests/propagate_contract.rs` pins this contract — destination, sign,
240    /// magnitude, and delivery tick — against a fixed four-neuron graph, and
241    /// is a copyable starting point for your own simulation loop.
242    ///
243    /// This allocating wrapper is source-compatible with existing callers.
244    /// Fixed-rate loops that already own an output buffer should use
245    /// [`Self::propagate_into`] instead.
246    pub fn propagate(&mut self, source_spikes: &[bool]) -> Result<Vec<f32>> {
247        let n = self.graph.neuron_count();
248        require_len(source_spikes.len(), n, "propagate source_spikes")?;
249        let mut currents = vec![0.0; n];
250        self.propagate_into(source_spikes, &mut currents)?;
251        Ok(currents)
252    }
253
254    /// Propagate spikes into a caller-owned output buffer.
255    ///
256    /// Functionally identical to [`Self::propagate`]: same destinations,
257    /// signs, magnitudes, and delivery ticks. Writes this tick's synaptic
258    /// currents into `output` instead of allocating a new `Vec<f32>`.
259    /// After [`Self::new`], a successful call performs no heap allocations.
260    ///
261    /// # Buffer contract
262    ///
263    /// - `source_spikes` and `output` must both have length `neuron_count()`.
264    /// - `output` is **overwritten** with this tick's delivered currents. It
265    ///   is not accumulated into; values present before the call are discarded
266    ///   on success.
267    /// - `source_spikes` (`&[bool]`) and `output` (`&mut [f32]`) cannot alias.
268    ///   Rust's borrow checker rejects overlapping borrows of the same memory.
269    ///
270    /// # Error atomicity
271    ///
272    /// If this method returns `Err`, the mesh tick, delay buffers, and
273    /// `output` are left unchanged. Length mismatches are rejected before any
274    /// mutation.
275    ///
276    /// ```rust
277    /// use synaptic_wiring::mesh::SynapticMesh;
278    /// use synaptic_wiring::topology::SynapticGraph;
279    /// use synaptic_wiring::types::{Polarity, SynapseDescriptor};
280    ///
281    /// let graph = SynapticGraph::from_descriptors(
282    ///     2,
283    ///     &[SynapseDescriptor {
284    ///         source: 0,
285    ///         target: 1,
286    ///         weight: 0.75,
287    ///         delay: 0,
288    ///         polarity: Polarity::Excitatory,
289    ///     }],
290    /// )?;
291    /// let mut mesh = SynapticMesh::new(graph);
292    /// let spikes = [true, false];
293    /// let mut currents = [0.0; 2];
294    /// mesh.propagate_into(&spikes, &mut currents)?;
295    /// assert_eq!(currents, [0.0, 0.75]);
296    /// # Ok::<(), synaptic_wiring::MeshError>(())
297    /// ```
298    pub fn propagate_into(&mut self, source_spikes: &[bool], output: &mut [f32]) -> Result<()> {
299        let n = self.graph.neuron_count();
300        require_len(source_spikes.len(), n, "propagate_into source_spikes")?;
301        require_len(output.len(), n, "propagate_into output")?;
302
303        for (src, &fired) in source_spikes.iter().enumerate() {
304            if !fired {
305                continue;
306            }
307            for (target, weight, delay, _polarity) in self.graph.outgoing(src) {
308                self.delay_buffer
309                    .inject(target as usize, weight, delay as usize);
310            }
311        }
312
313        self.delay_buffer.drain_current_tick_into(output)?;
314        self.delay_buffer.advance();
315        self.tick += 1;
316        Ok(())
317    }
318
319    /// Propagate with floating-point spike strengths instead of binary.
320    ///
321    /// # Arguments
322    ///
323    /// * `source_activations` — activation level for each neuron.
324    ///   Non-zero values are treated as spikes; the activation value
325    ///   scales the synaptic weight. Finite **signed** activations are
326    ///   allowed (a negative activation inverts the delivered current).
327    ///   NaN, ±infinity, non-finite `weight * activation` products, and
328    ///   non-finite per-slot aggregates (including current already in the
329    ///   delay buffer) are rejected before any buffer or tick mutation.
330    ///
331    /// This allocating wrapper is source-compatible with existing callers.
332    /// Fixed-rate loops that already own an output buffer should use
333    /// [`Self::propagate_graded_into`] instead.
334    pub fn propagate_graded(&mut self, source_activations: &[f32]) -> Result<Vec<f32>> {
335        let n = self.graph.neuron_count();
336        require_len(
337            source_activations.len(),
338            n,
339            "propagate_graded source_activations",
340        )?;
341        let mut currents = vec![0.0; n];
342        self.propagate_graded_into(source_activations, &mut currents)?;
343        Ok(currents)
344    }
345
346    /// Propagate graded activations into a caller-owned output buffer.
347    ///
348    /// Functionally identical to [`Self::propagate_graded`]: same destinations,
349    /// signs, magnitudes, and delivery ticks, including the same rejection of
350    /// non-finite activations, products, and per-slot aggregates. Writes this
351    /// tick's synaptic currents into `output` instead of allocating a new
352    /// `Vec<f32>`. After [`Self::new`], a successful call reuses internal
353    /// scratch and performs no heap allocations.
354    ///
355    /// # Buffer contract
356    ///
357    /// - `source_activations` and `output` must both have length
358    ///   `neuron_count()`.
359    /// - `output` is **overwritten** with this tick's delivered currents. It
360    ///   is not accumulated into; values present before the call are discarded
361    ///   on success.
362    /// - `source_activations` and `output` cannot alias. Rust's borrow checker
363    ///   rejects overlapping `&[f32]` / `&mut [f32]` borrows of the same
364    ///   memory, so in-place scaling of the output slice is not possible
365    ///   through this API.
366    ///
367    /// # Error atomicity
368    ///
369    /// If this method returns `Err`, the mesh tick, delay buffers, and
370    /// `output` are left unchanged. Length mismatches and non-finite
371    /// activations, products, or aggregates are rejected before any mutation.
372    pub fn propagate_graded_into(
373        &mut self,
374        source_activations: &[f32],
375        output: &mut [f32],
376    ) -> Result<()> {
377        let n = self.graph.neuron_count();
378        require_len(
379            source_activations.len(),
380            n,
381            "propagate_graded_into source_activations",
382        )?;
383        require_len(output.len(), n, "propagate_graded_into output")?;
384
385        // One traversal: validate each product, then group additions by
386        // (target, delay) so aggregates can be checked before any inject.
387        self.pending_scratch.clear();
388        for (src, &activation) in source_activations.iter().enumerate() {
389            if !activation.is_finite() {
390                self.pending_scratch.clear();
391                return Err(MeshError::InvalidConfig(format!(
392                    "propagate_graded source_activations[{src}] must be finite, got {activation}"
393                )));
394            }
395            if activation.abs() < 1e-9 {
396                continue;
397            }
398            for (target, weight, delay, _) in self.graph.outgoing(src) {
399                let current = weight * activation;
400                if !current.is_finite() {
401                    self.pending_scratch.clear();
402                    return Err(MeshError::InvalidConfig(format!(
403                        "propagate_graded source_activations[{src}] * synapse weight must be finite, got {current}"
404                    )));
405                }
406                accumulate_slot_current(
407                    &mut self.pending_scratch,
408                    target as usize,
409                    delay as usize,
410                    current,
411                );
412            }
413        }
414
415        for &(target, delay, additional) in &self.pending_scratch {
416            let total = self.delay_buffer.scheduled_current(target, delay) + additional;
417            if !additional.is_finite() || !total.is_finite() {
418                self.pending_scratch.clear();
419                return Err(MeshError::InvalidConfig(format!(
420                    "propagate_graded would produce a non-finite delay-buffer total for target {target}"
421                )));
422            }
423        }
424
425        for target in 0..n {
426            let current = self.delay_buffer.scheduled_current(target, 0);
427            if !current.is_finite() {
428                self.pending_scratch.clear();
429                return Err(MeshError::InvalidConfig(format!(
430                    "propagate_graded would produce a non-finite delay-buffer total for target {target}"
431                )));
432            }
433        }
434
435        for (target, delay, additional) in self.pending_scratch.iter().copied() {
436            self.delay_buffer.inject(target, additional, delay);
437        }
438        self.pending_scratch.clear();
439
440        self.delay_buffer.drain_current_tick_into(output)?;
441        self.delay_buffer.advance();
442        self.tick += 1;
443        Ok(())
444    }
445
446    /// Current simulation tick.
447    pub fn tick(&self) -> u64 {
448        self.tick
449    }
450
451    /// Number of neurons in the mesh.
452    pub fn neuron_count(&self) -> usize {
453        self.graph.neuron_count()
454    }
455
456    /// Total number of synapses.
457    pub fn synapse_count(&self) -> usize {
458        self.graph.synapse_count()
459    }
460
461    /// Sparsity of the connectivity matrix.
462    pub fn sparsity(&self) -> f32 {
463        self.graph.sparsity()
464    }
465
466    /// Maximum delay in ticks.
467    pub fn max_delay(&self) -> usize {
468        self.delay_buffer.max_delay()
469    }
470
471    /// Mean out-degree of the network.
472    pub fn mean_degree(&self) -> f32 {
473        self.graph.mean_degree()
474    }
475
476    /// Immutable access to the underlying graph.
477    pub fn graph(&self) -> &SynapticGraph {
478        &self.graph
479    }
480
481    /// Deterministic digest of the logical topology (not tick or delay-buffer state).
482    ///
483    /// Equivalent to [`SynapticGraph::topology_digest`] on [`Self::graph`].
484    #[must_use]
485    pub fn topology_digest(&self) -> TopologyDigest {
486        self.graph.topology_digest()
487    }
488
489    /// Reset delay buffer and tick counter.
490    pub fn reset(&mut self) {
491        self.delay_buffer.reset();
492        self.pending_scratch.clear();
493        self.tick = 0;
494    }
495
496    /// Export the CSR arrays + delays for GPU upload.
497    pub fn to_gpu_arrays(&self) -> (Vec<u32>, Vec<u32>, Vec<f32>, Vec<u16>) {
498        self.graph.to_gpu_arrays()
499    }
500}
501
502fn require_len(got: usize, expected: usize, context: &str) -> Result<()> {
503    if got != expected {
504        return Err(MeshError::NeuronCountMismatch {
505            expected,
506            got,
507            context: context.into(),
508        });
509    }
510    Ok(())
511}
512
513fn accumulate_slot_current(
514    pending: &mut Vec<(usize, usize, f32)>,
515    target: usize,
516    delay: usize,
517    current: f32,
518) {
519    if let Some((_, _, total)) = pending
520        .iter_mut()
521        .find(|(t, d, _)| *t == target && *d == delay)
522    {
523        *total += current;
524    } else {
525        pending.push((target, delay, current));
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::topology::{generate_layered, generate_random, generate_small_world};
533
534    #[test]
535    fn topology_digest_matches_graph_and_ignores_tick() {
536        let graph = two_neuron_delay_graph(1);
537        let mut mesh = SynapticMesh::new(graph.clone());
538        let before = mesh.topology_digest();
539        assert_eq!(before, graph.topology_digest());
540        let _ = mesh.propagate(&[true, false]).unwrap();
541        assert_eq!(
542            mesh.topology_digest(),
543            before,
544            "tick / delay-buffer state must not enter the topology digest"
545        );
546    }
547
548    fn two_neuron_delay_graph(delay: u16) -> SynapticGraph {
549        use crate::types::{Polarity, SynapseDescriptor};
550        SynapticGraph::from_descriptors(
551            2,
552            &[SynapseDescriptor {
553                source: 0,
554                target: 1,
555                weight: 1.0,
556                delay,
557                polarity: Polarity::Excitatory,
558            }],
559        )
560        .unwrap()
561    }
562
563    fn mesh_json_with_override(
564        mesh: &SynapticMesh,
565        patch: impl FnOnce(&mut serde_json::Value),
566    ) -> String {
567        let mut value = serde_json::to_value(mesh).unwrap();
568        patch(&mut value);
569        serde_json::to_string(&value).unwrap()
570    }
571
572    #[test]
573    fn deserialize_rejects_graph_buffer_neuron_count_mismatch() {
574        let mesh = SynapticMesh::new(two_neuron_delay_graph(0));
575        let json = mesh_json_with_override(&mesh, |v| {
576            v["delay_buffer"] = serde_json::to_value(SpikeDelayBuffer::new(3, 0)).unwrap();
577        });
578        assert!(serde_json::from_str::<SynapticMesh>(&json).is_err());
579    }
580
581    #[test]
582    fn deserialize_rejects_inadequate_buffer_capacity() {
583        let mesh = SynapticMesh::new(two_neuron_delay_graph(2));
584        let json = mesh_json_with_override(&mesh, |v| {
585            v["delay_buffer"] = serde_json::to_value(SpikeDelayBuffer::new(2, 1)).unwrap();
586        });
587        assert!(serde_json::from_str::<SynapticMesh>(&json).is_err());
588    }
589
590    #[test]
591    fn deserialize_rejects_tick_mismatch() {
592        let mesh = SynapticMesh::new(two_neuron_delay_graph(0));
593        let json = mesh_json_with_override(&mesh, |v| {
594            v["tick"] = serde_json::json!(3);
595        });
596        assert!(serde_json::from_str::<SynapticMesh>(&json).is_err());
597    }
598
599    #[test]
600    fn deserialize_rejects_tick_that_advances_to_usize_max() {
601        let tick = usize::MAX as u64 - 1;
602        for delay in [0_u16, 1] {
603            let mesh = SynapticMesh::new(two_neuron_delay_graph(delay));
604            let json = mesh_json_with_override(&mesh, |v| {
605                v["tick"] = serde_json::json!(tick);
606                v["delay_buffer"]["current_tick"] = serde_json::json!(tick);
607            });
608            assert!(
609                serde_json::from_str::<SynapticMesh>(&json).is_err(),
610                "synchronized tick usize::MAX - 1 with max_delay {delay} must be rejected"
611            );
612        }
613    }
614
615    #[test]
616    fn checkpoint_roundtrip_zero_delay_and_reset() {
617        let mut mesh = SynapticMesh::new(two_neuron_delay_graph(0));
618        let empty: SynapticMesh =
619            serde_json::from_str(&serde_json::to_string(&mesh).unwrap()).unwrap();
620        assert_eq!(empty.tick(), 0);
621
622        let _ = mesh.propagate(&[true, false]).unwrap();
623        mesh.reset();
624        let restored: SynapticMesh =
625            serde_json::from_str(&serde_json::to_string(&mesh).unwrap()).unwrap();
626        assert_eq!(restored.tick(), 0);
627        assert_eq!(
628            mesh.propagate(&[true, false]).unwrap(),
629            restored.clone().propagate(&[true, false]).unwrap()
630        );
631    }
632
633    /// Narrow in-flight regression. Generated graphs, checkpoint locations,
634    /// JSON/postcard restore, and the nightly profile live in
635    /// `tests/checkpoint_resume/` (LIM-1223).
636    #[test]
637    fn checkpoint_resume_with_spikes_in_flight_matches_live() {
638        let mut live = SynapticMesh::new(two_neuron_delay_graph(2));
639        // Inject an in-flight spike, then snapshot before it lands.
640        assert_eq!(live.propagate(&[true, false]).unwrap()[1], 0.0);
641        let json = serde_json::to_string(&live).unwrap();
642        let mut restored: SynapticMesh = serde_json::from_str(&json).unwrap();
643
644        // Continue both copies through more than one ring wrap (depth = 3).
645        let inputs = [
646            [false, false],
647            [true, false],
648            [false, false],
649            [false, false],
650            [true, false],
651            [false, false],
652            [false, false],
653            [false, false],
654        ];
655        for spikes in inputs {
656            let a = live.propagate(&spikes).unwrap();
657            let b = restored.propagate(&spikes).unwrap();
658            assert_eq!(a, b);
659            assert_eq!(live.tick(), restored.tick());
660        }
661    }
662
663    #[test]
664    fn try_with_max_delay_rejects_insufficient_capacity() {
665        use crate::types::{Polarity, SynapseDescriptor};
666        let graph = SynapticGraph::from_descriptors(
667            2,
668            &[SynapseDescriptor {
669                source: 0,
670                target: 1,
671                weight: 1.0,
672                delay: 2,
673                polarity: Polarity::Excitatory,
674            }],
675        )
676        .unwrap();
677        let err = SynapticMesh::try_with_max_delay(graph, 1).unwrap_err();
678        assert!(
679            err.to_string().contains("smaller than the graph"),
680            "unexpected error: {err}"
681        );
682    }
683
684    #[test]
685    #[should_panic(expected = "smaller than the graph")]
686    fn with_max_delay_rejects_insufficient_capacity() {
687        use crate::types::{Polarity, SynapseDescriptor};
688        let graph = SynapticGraph::from_descriptors(
689            2,
690            &[SynapseDescriptor {
691                source: 0,
692                target: 1,
693                weight: 1.0,
694                delay: 2,
695                polarity: Polarity::Excitatory,
696            }],
697        )
698        .unwrap();
699        let _ = SynapticMesh::with_max_delay(graph, 1);
700    }
701
702    #[test]
703    fn with_max_delay_accepts_equal_and_larger_capacity() {
704        use crate::types::{Polarity, SynapseDescriptor};
705        let graph = SynapticGraph::from_descriptors(
706            2,
707            &[SynapseDescriptor {
708                source: 0,
709                target: 1,
710                weight: 1.0,
711                delay: 2,
712                polarity: Polarity::Excitatory,
713            }],
714        )
715        .unwrap();
716        let equal = SynapticMesh::try_with_max_delay(graph.clone(), 2).unwrap();
717        assert_eq!(equal.max_delay(), 2);
718        let larger = SynapticMesh::with_max_delay(graph, 5);
719        assert_eq!(larger.max_delay(), 5);
720    }
721
722    #[test]
723    fn propagate_length_mismatch_rejected() {
724        let graph = generate_random(10, 0.5, 3, 0.2).unwrap();
725        let mut mesh = SynapticMesh::new(graph);
726        let bad_spikes = vec![false; 5]; // wrong length
727        assert!(mesh.propagate(&bad_spikes).is_err());
728    }
729
730    #[test]
731    fn no_spikes_no_current() {
732        let graph = generate_random(10, 0.5, 3, 0.2).unwrap();
733        let mut mesh = SynapticMesh::new(graph);
734        let spikes = vec![false; 10];
735        let currents = mesh.propagate(&spikes).unwrap();
736        assert!(currents.iter().all(|&c| c == 0.0));
737    }
738
739    #[test]
740    fn instant_delivery_with_zero_delay() {
741        // Build a 2-neuron graph with 0-delay connection: 0 → 1
742        use crate::types::{Polarity, SynapseDescriptor};
743        let desc = vec![SynapseDescriptor {
744            source: 0,
745            target: 1,
746            weight: 0.8,
747            delay: 0,
748            polarity: Polarity::Excitatory,
749        }];
750        let graph = SynapticGraph::from_descriptors(2, &desc).unwrap();
751        let mut mesh = SynapticMesh::new(graph);
752
753        let mut spikes = vec![false; 2];
754        spikes[0] = true;
755        let currents = mesh.propagate(&spikes).unwrap();
756        assert!((currents[1] - 0.8).abs() < 1e-6);
757    }
758
759    #[test]
760    fn delayed_delivery_through_mesh() {
761        use crate::types::{Polarity, SynapseDescriptor};
762        let desc = vec![SynapseDescriptor {
763            source: 0,
764            target: 1,
765            weight: 1.0,
766            delay: 2,
767            polarity: Polarity::Excitatory,
768        }];
769        let graph = SynapticGraph::from_descriptors(2, &desc).unwrap();
770        let mut mesh = SynapticMesh::new(graph);
771
772        // Tick 0: neuron 0 fires
773        let mut spikes = vec![false; 2];
774        spikes[0] = true;
775        let c0 = mesh.propagate(&spikes).unwrap();
776        assert_eq!(c0[1], 0.0); // not arrived yet
777
778        // Tick 1: no fires
779        spikes[0] = false;
780        let c1 = mesh.propagate(&spikes).unwrap();
781        assert_eq!(c1[1], 0.0); // not arrived yet
782
783        // Tick 2: spike arrives
784        let c2 = mesh.propagate(&spikes).unwrap();
785        assert!((c2[1] - 1.0).abs() < 1e-6);
786    }
787
788    #[test]
789    fn propagate_graded_rejects_non_finite_before_mutation() {
790        use crate::types::{Polarity, SynapseDescriptor};
791        let graph = SynapticGraph::from_descriptors(
792            2,
793            &[SynapseDescriptor {
794                source: 0,
795                target: 1,
796                weight: 1.0,
797                delay: 2,
798                polarity: Polarity::Excitatory,
799            }],
800        )
801        .unwrap();
802        let mut mesh = SynapticMesh::new(graph);
803
804        // Put a spike in flight so we can detect a partial tick advance.
805        mesh.propagate(&[true, false]).unwrap();
806        assert_eq!(mesh.tick(), 1);
807
808        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
809            let err = mesh.propagate_graded(&[0.0, bad]).unwrap_err();
810            assert!(
811                err.to_string().contains("must be finite"),
812                "unexpected error for {bad}: {err}"
813            );
814            assert_eq!(mesh.tick(), 1, "tick must not advance on rejection");
815        }
816
817        // In-flight delay-2 current must still arrive two ticks after inject
818        // (tick 2 of the mesh), proving the failed graded calls did not drain.
819        assert_eq!(mesh.propagate(&[false, false]).unwrap()[1], 0.0);
820        let arrived = mesh.propagate(&[false, false]).unwrap();
821        assert!((arrived[1] - 1.0).abs() < 1e-6);
822    }
823
824    #[test]
825    fn propagate_graded_prevalidates_later_elements() {
826        use crate::types::{Polarity, SynapseDescriptor};
827        let graph = SynapticGraph::from_descriptors(
828            3,
829            &[
830                SynapseDescriptor {
831                    source: 0,
832                    target: 1,
833                    weight: 1.0,
834                    delay: 0,
835                    polarity: Polarity::Excitatory,
836                },
837                SynapseDescriptor {
838                    source: 2,
839                    target: 1,
840                    weight: 1.0,
841                    delay: 0,
842                    polarity: Polarity::Excitatory,
843                },
844            ],
845        )
846        .unwrap();
847        let mut mesh = SynapticMesh::new(graph);
848        // A valid first element must not be injected if a later one is NaN.
849        assert!(mesh.propagate_graded(&[1.0, 0.0, f32::NAN]).is_err());
850        assert_eq!(mesh.tick(), 0);
851        let currents = mesh.propagate(&[false, false, false]).unwrap();
852        assert!(currents.iter().all(|&c| c == 0.0));
853    }
854
855    #[test]
856    fn propagate_graded_rejects_non_finite_weight_activation_product() {
857        use crate::types::{Polarity, SynapseDescriptor};
858        let graph = SynapticGraph::from_descriptors(
859            3,
860            &[
861                SynapseDescriptor {
862                    source: 0,
863                    target: 1,
864                    weight: 1.0,
865                    delay: 2,
866                    polarity: Polarity::Excitatory,
867                },
868                SynapseDescriptor {
869                    source: 2,
870                    target: 1,
871                    weight: f32::MAX,
872                    delay: 0,
873                    polarity: Polarity::Excitatory,
874                },
875            ],
876        )
877        .unwrap();
878        let mut mesh = SynapticMesh::new(graph);
879
880        mesh.propagate(&[true, false, false]).unwrap();
881        assert_eq!(mesh.tick(), 1);
882
883        let err = mesh.propagate_graded(&[0.0, 0.0, f32::MAX]).unwrap_err();
884        assert!(
885            err.to_string().contains("must be finite"),
886            "unexpected error: {err}"
887        );
888        assert_eq!(
889            mesh.tick(),
890            1,
891            "tick must not advance on overflow rejection"
892        );
893
894        // A later overflowing product must not inject the earlier finite synapse.
895        assert!(mesh.propagate_graded(&[1.0, 0.0, f32::MAX]).is_err());
896        assert_eq!(mesh.tick(), 1);
897
898        assert_eq!(mesh.propagate(&[false, false, false]).unwrap()[1], 0.0);
899        let arrived = mesh.propagate(&[false, false, false]).unwrap();
900        assert!(
901            (arrived[1] - 1.0).abs() < 1e-6,
902            "in-flight current must be unchanged, got {}",
903            arrived[1]
904        );
905    }
906
907    #[test]
908    fn propagate_graded_rejects_aggregate_overflow_before_mutation() {
909        use crate::types::{Polarity, SynapseDescriptor};
910        let graph = SynapticGraph::from_descriptors(
911            3,
912            &[
913                SynapseDescriptor {
914                    source: 0,
915                    target: 1,
916                    weight: f32::MAX,
917                    delay: 0,
918                    polarity: Polarity::Excitatory,
919                },
920                SynapseDescriptor {
921                    source: 2,
922                    target: 1,
923                    weight: f32::MAX,
924                    delay: 0,
925                    polarity: Polarity::Excitatory,
926                },
927            ],
928        )
929        .unwrap();
930        let mut mesh = SynapticMesh::new(graph);
931
932        let err = mesh.propagate_graded(&[1.0, 0.0, 1.0]).unwrap_err();
933        assert!(
934            err.to_string().contains("non-finite delay-buffer total"),
935            "unexpected error: {err}"
936        );
937        assert_eq!(mesh.tick(), 0);
938        let currents = mesh.propagate(&[false, false, false]).unwrap();
939        assert!(currents.iter().all(|&c| c == 0.0));
940    }
941
942    #[test]
943    fn propagate_graded_rejects_overflow_with_existing_buffer_current() {
944        use crate::types::{Polarity, SynapseDescriptor};
945        let graph = SynapticGraph::from_descriptors(
946            2,
947            &[
948                SynapseDescriptor {
949                    source: 0,
950                    target: 1,
951                    weight: f32::MAX,
952                    delay: 0,
953                    polarity: Polarity::Excitatory,
954                },
955                SynapseDescriptor {
956                    source: 0,
957                    target: 1,
958                    weight: f32::MAX,
959                    delay: 1,
960                    polarity: Polarity::Excitatory,
961                },
962            ],
963        )
964        .unwrap();
965        let mut mesh = SynapticMesh::new(graph);
966
967        // Parks f32::MAX in the delay-1 slot; delay-0 is drained this tick.
968        let first = mesh.propagate_graded(&[1.0, 0.0]).unwrap();
969        assert_eq!(first[1], f32::MAX);
970        assert_eq!(mesh.tick(), 1);
971
972        let err = mesh.propagate_graded(&[1.0, 0.0]).unwrap_err();
973        assert!(
974            err.to_string().contains("non-finite delay-buffer total"),
975            "unexpected error: {err}"
976        );
977        assert_eq!(
978            mesh.tick(),
979            1,
980            "tick must not advance on aggregate rejection"
981        );
982
983        let arrived = mesh.propagate(&[false, false]).unwrap();
984        assert_eq!(
985            arrived[1],
986            f32::MAX,
987            "parked delay-1 current must be unchanged"
988        );
989    }
990
991    #[test]
992    fn propagate_graded_preserves_signed_finite_activations() {
993        use crate::types::{Polarity, SynapseDescriptor};
994        let graph = SynapticGraph::from_descriptors(
995            2,
996            &[SynapseDescriptor {
997                source: 0,
998                target: 1,
999                weight: 0.5,
1000                delay: 0,
1001                polarity: Polarity::Inhibitory,
1002            }],
1003        )
1004        .unwrap();
1005        let mut mesh = SynapticMesh::new(graph);
1006        // Inhibitory weight is -0.5; a negative activation inverts it to +0.25.
1007        let currents = mesh.propagate_graded(&[-0.5, 0.0]).unwrap();
1008        assert!((currents[1] - 0.25).abs() < 1e-6);
1009    }
1010
1011    #[test]
1012    fn graded_propagation() {
1013        use crate::types::{Polarity, SynapseDescriptor};
1014        let desc = vec![SynapseDescriptor {
1015            source: 0,
1016            target: 1,
1017            weight: 0.5,
1018            delay: 0,
1019            polarity: Polarity::Excitatory,
1020        }];
1021        let graph = SynapticGraph::from_descriptors(2, &desc).unwrap();
1022        let mut mesh = SynapticMesh::new(graph);
1023
1024        let activations = vec![0.6, 0.0];
1025        let currents = mesh.propagate_graded(&activations).unwrap();
1026        // 0.5 * 0.6 = 0.3
1027        assert!((currents[1] - 0.3).abs() < 1e-6);
1028    }
1029
1030    #[test]
1031    fn tick_counter_increments() {
1032        let graph = generate_random(8, 0.3, 2, 0.2).unwrap();
1033        let mut mesh = SynapticMesh::new(graph);
1034        assert_eq!(mesh.tick(), 0);
1035        mesh.propagate(&[false; 8]).unwrap();
1036        mesh.propagate(&[false; 8]).unwrap();
1037        assert_eq!(mesh.tick(), 2);
1038    }
1039
1040    #[test]
1041    fn reset_clears_state() {
1042        let graph = generate_random(8, 0.3, 2, 0.2).unwrap();
1043        let mut mesh = SynapticMesh::new(graph);
1044        mesh.propagate(&[true; 8]).unwrap();
1045        mesh.propagate(&[true; 8]).unwrap();
1046        mesh.reset();
1047        assert_eq!(mesh.tick(), 0);
1048    }
1049
1050    #[test]
1051    fn small_world_mesh_propagation() {
1052        let graph = generate_small_world(32, 4, 0.2, 5, 0.2).unwrap();
1053        let mut mesh = SynapticMesh::new(graph);
1054
1055        // Fire a single neuron and run for several ticks
1056        let mut any_current = false;
1057        for tick in 0..10 {
1058            let mut spikes = vec![false; 32];
1059            if tick == 0 {
1060                spikes[0] = true;
1061            }
1062            let currents = mesh.propagate(&spikes).unwrap();
1063            if currents.iter().any(|&c| c.abs() > 1e-6) {
1064                any_current = true;
1065            }
1066        }
1067        assert!(any_current, "expected some delayed current delivery");
1068    }
1069
1070    #[test]
1071    fn layered_mesh_feed_forward() {
1072        // generate_layered(&[4, 8, 2], inter_layer_p, max_delay, inh_fraction)
1073        // Neuron layout: [0..4) input, [4..12) hidden, [12..14) output
1074        //
1075        // NOTE: This test depends on the deterministic hash-based weight
1076        // generation. The threshold (0.5) and inhibitory fraction (0.2) are
1077        // chosen so that excitatory contributions dominate for enough hidden
1078        // neurons to activate the output layer. If the hash function or weight
1079        // parameters change, the threshold or layer sizes may need adjustment.
1080        let graph = generate_layered(&[4, 8, 2], 1.0, 3, 0.2).unwrap();
1081        let mut mesh = SynapticMesh::new(graph);
1082        assert_eq!(mesh.neuron_count(), 14);
1083        let mut spikes = vec![false; 14];
1084        for spike in spikes.iter_mut().take(4) {
1085            *spike = true; // fire input layer
1086        }
1087
1088        // Run enough ticks for delays to propagate through both hops
1089        // (max_delay=3 per hop → need at least 6 ticks; run 12 for margin).
1090        let mut last_layer_activated = false;
1091        for _ in 0..12 {
1092            let currents = mesh.propagate(&spikes).unwrap();
1093
1094            // Threshold-and-fire: neurons that received enough current fire
1095            // next tick. This mirrors how a downstream consumer would use
1096            // `propagate()` to build a multi-layer simulation loop.
1097            spikes = currents.iter().map(|&c| c > 0.5).collect();
1098
1099            // Check if last-layer neurons (12, 13) received current
1100            if currents[12].abs() > 1e-6 || currents[13].abs() > 1e-6 {
1101                last_layer_activated = true;
1102            }
1103        }
1104        assert!(
1105            last_layer_activated,
1106            "feed-forward should eventually reach the output layer"
1107        );
1108    }
1109
1110    fn assert_meshes_match(left: &mut SynapticMesh, right: &mut SynapticMesh, spikes: &[bool]) {
1111        let allocated = left.propagate(spikes).unwrap();
1112        let mut reused = vec![7.0; spikes.len()];
1113        right.propagate_into(spikes, &mut reused).unwrap();
1114        assert_eq!(allocated, reused);
1115        assert_eq!(left.tick(), right.tick());
1116    }
1117
1118    #[test]
1119    fn propagate_into_matches_allocating_zero_and_delayed() {
1120        for delay in [0_u16, 2] {
1121            let graph = two_neuron_delay_graph(delay);
1122            let mut alloc = SynapticMesh::new(graph.clone());
1123            let mut reuse = SynapticMesh::new(graph);
1124            assert_meshes_match(&mut alloc, &mut reuse, &[true, false]);
1125            assert_meshes_match(&mut alloc, &mut reuse, &[false, false]);
1126            assert_meshes_match(&mut alloc, &mut reuse, &[false, false]);
1127        }
1128    }
1129
1130    #[test]
1131    fn propagate_graded_into_matches_allocating_signed_and_delayed() {
1132        let graph = two_neuron_delay_graph(2);
1133        let mut alloc = SynapticMesh::new(graph.clone());
1134        let mut reuse = SynapticMesh::new(graph);
1135        for activations in [[0.5, 0.0], [0.0, 0.0], [-0.25, 0.0], [0.0, 0.0]] {
1136            let allocated = alloc.propagate_graded(&activations).unwrap();
1137            let mut reused = vec![7.0; 2];
1138            reuse
1139                .propagate_graded_into(&activations, &mut reused)
1140                .unwrap();
1141            assert_eq!(allocated, reused);
1142            assert_eq!(alloc.tick(), reuse.tick());
1143        }
1144    }
1145
1146    #[test]
1147    fn propagate_into_rejects_wrong_sizes_without_mutation() {
1148        let mut mesh = SynapticMesh::new(two_neuron_delay_graph(2));
1149        mesh.propagate(&[true, false]).unwrap();
1150        let tick = mesh.tick();
1151        let mut sentinel = [42.0_f32, 42.0];
1152
1153        assert!(mesh.propagate_into(&[true], &mut sentinel).is_err());
1154        assert_eq!(mesh.tick(), tick);
1155        assert_eq!(sentinel, [42.0, 42.0]);
1156
1157        let mut too_short = [42.0_f32];
1158        assert!(mesh.propagate_into(&[true, false], &mut too_short).is_err());
1159        assert_eq!(mesh.tick(), tick);
1160        assert_eq!(too_short, [42.0]);
1161
1162        // In-flight delay-2 current must still arrive two ticks after inject.
1163        assert_eq!(mesh.propagate(&[false, false]).unwrap()[1], 0.0);
1164        let arrived = mesh.propagate(&[false, false]).unwrap();
1165        assert!((arrived[1] - 1.0).abs() < 1e-6);
1166    }
1167
1168    #[test]
1169    fn propagate_graded_into_rejects_non_finite_without_mutating_output() {
1170        let mut mesh = SynapticMesh::new(two_neuron_delay_graph(2));
1171        mesh.propagate(&[true, false]).unwrap();
1172        let tick = mesh.tick();
1173        let mut sentinel = [42.0_f32, 42.0];
1174
1175        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1176            let err = mesh
1177                .propagate_graded_into(&[0.0, bad], &mut sentinel)
1178                .unwrap_err();
1179            assert!(
1180                err.to_string().contains("must be finite"),
1181                "unexpected error for {bad}: {err}"
1182            );
1183            assert_eq!(mesh.tick(), tick);
1184            assert_eq!(sentinel, [42.0, 42.0]);
1185        }
1186
1187        assert_eq!(mesh.propagate(&[false, false]).unwrap()[1], 0.0);
1188        let arrived = mesh.propagate(&[false, false]).unwrap();
1189        assert!((arrived[1] - 1.0).abs() < 1e-6);
1190    }
1191
1192    #[test]
1193    fn checkpoint_omits_pending_scratch() {
1194        let mesh = SynapticMesh::new(two_neuron_delay_graph(0));
1195        let value = serde_json::to_value(&mesh).unwrap();
1196        assert!(value.get("pending_scratch").is_none());
1197        let restored: SynapticMesh = serde_json::from_value(value).unwrap();
1198        assert_eq!(restored.neuron_count(), 2);
1199        assert_eq!(restored.tick(), 0);
1200    }
1201
1202    #[test]
1203    fn propagate_graded_into_rejects_non_finite_current_in_delay_buffer_with_empty_scratch() {
1204        let mut mesh = SynapticMesh::new(two_neuron_delay_graph(0));
1205        mesh.delay_buffer.inject(0, f32::NAN, 0);
1206        let tick = mesh.tick();
1207        let mut sentinel = [42.0_f32, 42.0];
1208
1209        let err = mesh
1210            .propagate_graded_into(&[0.0, 0.0], &mut sentinel)
1211            .unwrap_err();
1212        assert!(
1213            err.to_string().contains("non-finite delay-buffer total"),
1214            "unexpected error: {err}"
1215        );
1216        assert_eq!(mesh.tick(), tick);
1217        assert_eq!(sentinel, [42.0, 42.0]);
1218        assert!(mesh.delay_buffer.scheduled_current(0, 0).is_nan());
1219    }
1220}