Skip to main content

synaptic_wiring/delay/
ring_buffer.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Ring-buffer delay queue for tick-aligned spike delivery.
4//!
5//! The [`SpikeDelayBuffer`] implements a fixed-size circular buffer where
6//! spikes are injected at the current tick plus a per-synapse delay, and
7//! delivered (drained) at each tick advance.
8//!
9//! # Design
10//!
11//! ```text
12//! tick 0:  inject spike at delay=3  →  buffer[3] += weight
13//! tick 1:  ...
14//! tick 2:  ...
15//! tick 3:  drain buffer[3]  →  deliver accumulated current to target neuron
16//! ```
17//!
18//! The ring buffer has `max_delay + 1` slots, each slot is a vector of
19//! length `neuron_count` accumulating incoming synaptic current.
20
21use serde::de::{Deserializer, Error as DeError};
22use serde::{Deserialize, Serialize};
23
24use crate::error::{MeshError, Result};
25
26/// Ring-buffer delay queue for spike delivery.
27///
28/// At each simulation tick:
29/// 1. Call [`SpikeDelayBuffer::inject`] for each spiking synapse to schedule future delivery.
30/// 2. Call [`SpikeDelayBuffer::drain_current_tick`] or
31///    [`SpikeDelayBuffer::drain_current_tick_into`] to collect all currents
32///    that have arrived.
33/// 3. Call [`SpikeDelayBuffer::advance`] to move the tick forward.
34///
35/// With `max_delay == 0` the buffer holds a single slot and every spike is
36/// delivered in the same tick it is injected, behaving as if there were no
37/// delay layer — it still allocates that one slot, one `f32` per neuron.
38///
39/// Deserialization re-validates the ring invariants: depth is
40/// `max_delay + 1` (checked, nonzero), every slot width equals
41/// `neuron_count`, every stored current is finite, `current_tick + max_delay`
42/// fits in `usize` so inject/drain slot arithmetic cannot overflow, and
43/// `current_tick` leaves one-tick headroom so `advance` cannot land on
44/// `usize::MAX`. Empty-neuron buffers (`neuron_count == 0`) remain legal
45/// when each slot is an empty vector.
46#[derive(Clone, Debug, Serialize)]
47pub struct SpikeDelayBuffer {
48    /// Ring buffer: `slots[slot_index][neuron_id]` → accumulated current.
49    slots: Vec<Vec<f32>>,
50    /// Number of neurons (width of each slot).
51    neuron_count: usize,
52    /// Maximum delay in ticks (depth of the ring buffer minus 1).
53    max_delay: usize,
54    /// Current simulation tick.
55    current_tick: u64,
56}
57
58#[derive(Deserialize)]
59struct RawSpikeDelayBuffer {
60    slots: Vec<Vec<f32>>,
61    neuron_count: usize,
62    max_delay: usize,
63    current_tick: u64,
64}
65
66impl RawSpikeDelayBuffer {
67    fn into_buffer(self) -> std::result::Result<SpikeDelayBuffer, String> {
68        validate_delay_buffer_shape(&self.slots, self.neuron_count, self.max_delay)?;
69        validate_current_tick(self.current_tick, self.max_delay)?;
70        Ok(SpikeDelayBuffer {
71            slots: self.slots,
72            neuron_count: self.neuron_count,
73            max_delay: self.max_delay,
74            current_tick: self.current_tick,
75        })
76    }
77}
78
79/// Ring depth must be `max_delay + 1` (nonzero, no overflow) and every
80/// slot must have width `neuron_count` with only finite currents.
81fn validate_delay_buffer_shape(
82    slots: &[Vec<f32>],
83    neuron_count: usize,
84    max_delay: usize,
85) -> std::result::Result<(), String> {
86    let expected_depth = max_delay.checked_add(1).ok_or_else(|| {
87        format!("max_delay {max_delay} + 1 overflows usize; delay buffer depth is invalid")
88    })?;
89    if slots.is_empty() {
90        return Err("delay buffer slots must be non-empty".into());
91    }
92    if slots.len() != expected_depth {
93        return Err(format!(
94            "delay buffer depth {} does not match max_delay + 1 ({expected_depth})",
95            slots.len()
96        ));
97    }
98    for (i, slot) in slots.iter().enumerate() {
99        if slot.len() != neuron_count {
100            return Err(format!(
101                "delay buffer slot {i} width {} does not match neuron_count {neuron_count}",
102                slot.len()
103            ));
104        }
105        if slot.iter().any(|v| !v.is_finite()) {
106            return Err(format!(
107                "delay buffer slot {i} contains a non-finite current; refusing to poison later drain_current_tick sums"
108            ));
109        }
110    }
111    Ok(())
112}
113
114/// `advance` (`+= 1`) must not land on `usize::MAX`, and
115/// `current_tick + max_delay` must fit in `usize` so inject slot arithmetic
116/// cannot wrap.
117pub(crate) fn validate_current_tick(
118    current_tick: u64,
119    max_delay: usize,
120) -> std::result::Result<(), String> {
121    if current_tick >= usize::MAX as u64 {
122        return Err("current_tick is too large for safe advancement and indexing".into());
123    }
124    let max_safe_tick = (usize::MAX as u64).saturating_sub(max_delay as u64);
125    if current_tick > max_safe_tick {
126        return Err(format!(
127            "current_tick {current_tick} is too large to add max_delay {max_delay} without overflowing usize indexing"
128        ));
129    }
130    // One subsequent `+= 1` must stay strictly below `usize::MAX`, even when
131    // `max_delay` is 0 or 1 and inject arithmetic would otherwise allow
132    // `current_tick == usize::MAX - 1`.
133    if current_tick >= (usize::MAX as u64).saturating_sub(1) {
134        return Err("current_tick is too large for safe advancement and indexing".into());
135    }
136    Ok(())
137}
138
139impl<'de> Deserialize<'de> for SpikeDelayBuffer {
140    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
141    where
142        D: Deserializer<'de>,
143    {
144        RawSpikeDelayBuffer::deserialize(deserializer)?
145            .into_buffer()
146            .map_err(DeError::custom)
147    }
148}
149
150impl SpikeDelayBuffer {
151    /// Create a new delay buffer.
152    ///
153    /// The ring has `max_delay + 1` slots so a spike injected with
154    /// `delay == max_delay` lands on a future tick rather than wrapping
155    /// onto the current slot.
156    ///
157    /// # Arguments
158    ///
159    /// * `neuron_count` — number of target neurons (slot width)
160    /// * `max_delay` — maximum axonal delay in ticks
161    ///
162    /// # Panics
163    ///
164    /// Panics if `max_delay + 1` overflows `usize`. Prefer
165    /// [`SpikeDelayBuffer::try_new`] when the caller needs a recoverable
166    /// error.
167    pub fn new(neuron_count: usize, max_delay: usize) -> Self {
168        Self::try_new(neuron_count, max_delay).unwrap_or_else(|err| panic!("{err}"))
169    }
170
171    /// Fallible constructor that rejects a `max_delay` whose ring depth
172    /// (`max_delay + 1`) would overflow `usize`.
173    pub fn try_new(neuron_count: usize, max_delay: usize) -> Result<Self> {
174        let depth = max_delay.checked_add(1).ok_or_else(|| {
175            MeshError::DelayError(format!(
176                "max_delay {max_delay} + 1 overflows usize; ring depth cannot be represented"
177            ))
178        })?;
179        Ok(Self {
180            slots: vec![vec![0.0; neuron_count]; depth],
181            neuron_count,
182            max_delay,
183            current_tick: 0,
184        })
185    }
186
187    /// Inject a spike from a source neuron through a synapse.
188    ///
189    /// The synaptic current `weight` will be delivered to `target` neuron
190    /// after `delay` ticks from the current tick.
191    ///
192    /// Bounds are checked in **both** debug and release builds before any
193    /// slot is modified. An oversized delay is rejected rather than
194    /// wrapping onto an earlier tick via modulo arithmetic.
195    ///
196    /// # Panics
197    ///
198    /// Panics if `delay > max_delay` or `target >= neuron_count`. Prefer
199    /// [`SpikeDelayBuffer::try_inject`] when the caller needs a recoverable
200    /// error.
201    #[inline]
202    pub fn inject(&mut self, target: usize, weight: f32, delay: usize) {
203        self.try_inject(target, weight, delay)
204            .unwrap_or_else(|err| panic!("{err}"))
205    }
206
207    /// Fallible inject that leaves the buffer unchanged when `delay` or
208    /// `target` is out of range.
209    #[inline]
210    pub fn try_inject(&mut self, target: usize, weight: f32, delay: usize) -> Result<()> {
211        if delay > self.max_delay {
212            return Err(MeshError::DelayError(format!(
213                "delay {delay} exceeds max_delay {}",
214                self.max_delay
215            )));
216        }
217        if target >= self.neuron_count {
218            return Err(MeshError::IndexOutOfBounds {
219                index: target,
220                max: self.neuron_count.saturating_sub(1),
221            });
222        }
223        // Constructors keep depth == max_delay + 1, but a deserialized
224        // ring can still be shorter. Refuse delay >= depth so we never
225        // wrap onto an earlier tick.
226        let depth = self.slots.len();
227        if depth == 0 || delay >= depth {
228            return Err(MeshError::DelayError(format!(
229                "delay {delay} exceeds buffer depth {}",
230                depth.saturating_sub(1)
231            )));
232        }
233        let slot_idx = self.slot_index(delay);
234        self.slots[slot_idx][target] += weight;
235        Ok(())
236    }
237
238    /// Current scheduled at `target` for `delay` ticks from now.
239    #[inline]
240    pub(crate) fn scheduled_current(&self, target: usize, delay: usize) -> f32 {
241        debug_assert!(
242            delay <= self.max_delay,
243            "delay {delay} > max_delay {}",
244            self.max_delay
245        );
246        debug_assert!(
247            target < self.neuron_count,
248            "target {target} >= neuron_count {}",
249            self.neuron_count
250        );
251        self.slots[self.slot_index(delay)][target]
252    }
253
254    /// Reduce `current_tick` modulo ring depth before adding `delay` so
255    /// `current_tick + delay` cannot overflow `usize`.
256    #[inline]
257    fn slot_index(&self, delay: usize) -> usize {
258        let depth = self.slots.len();
259        debug_assert!(depth > 0);
260        let tick_mod = (self.current_tick % depth as u64) as usize;
261        tick_mod.wrapping_add(delay) % depth
262    }
263
264    /// Drain the current tick's accumulated synaptic currents.
265    ///
266    /// Returns a vector of length `neuron_count` with the total synaptic
267    /// current arriving at each neuron in this tick. The slot is zeroed
268    /// after draining.
269    ///
270    /// Prefer [`SpikeDelayBuffer::drain_current_tick_into`] when the caller
271    /// can reuse an output buffer.
272    pub fn drain_current_tick(&mut self) -> Vec<f32> {
273        let mut currents = vec![0.0; self.neuron_count];
274        self.drain_current_tick_into(&mut currents)
275            .expect("output length matches neuron_count");
276        currents
277    }
278
279    /// Drain the current tick into a caller-owned buffer.
280    ///
281    /// `output` is **overwritten** with this tick's currents and must have
282    /// length `neuron_count()`. On success the drained slot is zeroed. On
283    /// a length mismatch the buffer is left unchanged — `output` is not
284    /// written and no slot is cleared.
285    pub fn drain_current_tick_into(&mut self, output: &mut [f32]) -> Result<()> {
286        if output.len() != self.neuron_count {
287            return Err(MeshError::NeuronCountMismatch {
288                expected: self.neuron_count,
289                got: output.len(),
290                context: "drain_current_tick_into output".into(),
291            });
292        }
293        let slot_idx = self.slot_index(0);
294        output.copy_from_slice(&self.slots[slot_idx]);
295        self.slots[slot_idx].fill(0.0);
296        Ok(())
297    }
298
299    /// Advance to the next tick.
300    pub fn advance(&mut self) {
301        self.current_tick += 1;
302    }
303
304    /// Current simulation tick.
305    pub fn current_tick(&self) -> u64 {
306        self.current_tick
307    }
308
309    /// Maximum delay supported by this buffer.
310    pub fn max_delay(&self) -> usize {
311        self.max_delay
312    }
313
314    /// Number of neurons (slot width).
315    pub fn neuron_count(&self) -> usize {
316        self.neuron_count
317    }
318
319    /// Reset all slots and the tick counter.
320    pub fn reset(&mut self) {
321        for slot in &mut self.slots {
322            slot.fill(0.0);
323        }
324        self.current_tick = 0;
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn zero_delay_delivers_same_tick() {
334        let mut buf = SpikeDelayBuffer::new(4, 0);
335        buf.inject(2, 0.75, 0);
336        let currents = buf.drain_current_tick();
337        assert!((currents[2] - 0.75).abs() < 1e-6);
338        assert_eq!(currents[0], 0.0);
339    }
340
341    #[test]
342    fn delayed_delivery() {
343        let mut buf = SpikeDelayBuffer::new(4, 5);
344
345        // Inject at tick 0 with delay 3 → should arrive at tick 3
346        buf.inject(1, 0.5, 3);
347
348        // Tick 0: nothing delivered to neuron 1
349        let c0 = buf.drain_current_tick();
350        assert_eq!(c0[1], 0.0);
351        buf.advance();
352
353        // Tick 1: nothing
354        let c1 = buf.drain_current_tick();
355        assert_eq!(c1[1], 0.0);
356        buf.advance();
357
358        // Tick 2: nothing
359        let c2 = buf.drain_current_tick();
360        assert_eq!(c2[1], 0.0);
361        buf.advance();
362
363        // Tick 3: delivered!
364        let c3 = buf.drain_current_tick();
365        assert!((c3[1] - 0.5).abs() < 1e-6);
366    }
367
368    #[test]
369    fn multiple_spikes_accumulate() {
370        let mut buf = SpikeDelayBuffer::new(4, 5);
371        buf.inject(0, 0.3, 2);
372        buf.inject(0, 0.7, 2);
373
374        buf.advance();
375        buf.advance();
376
377        let currents = buf.drain_current_tick();
378        assert!((currents[0] - 1.0).abs() < 1e-6);
379    }
380
381    #[test]
382    fn ring_buffer_wraps_correctly() {
383        let mut buf = SpikeDelayBuffer::new(2, 3);
384
385        // Run for more ticks than the ring buffer depth
386        for tick in 0..10 {
387            buf.inject(0, 1.0, 2);
388            let currents = buf.drain_current_tick();
389            if tick >= 2 {
390                // After tick 2, we should receive the spike injected 2 ticks ago
391                assert!(
392                    (currents[0] - 1.0).abs() < 1e-6,
393                    "tick {tick}: expected 1.0, got {}",
394                    currents[0]
395                );
396            }
397            buf.advance();
398        }
399    }
400
401    #[test]
402    fn reset_clears_everything() {
403        let mut buf = SpikeDelayBuffer::new(4, 5);
404        buf.inject(0, 0.5, 3);
405        buf.advance();
406        buf.advance();
407        buf.reset();
408
409        assert_eq!(buf.current_tick(), 0);
410        let currents = buf.drain_current_tick();
411        assert!(currents.iter().all(|&v| v == 0.0));
412    }
413
414    #[test]
415    fn inject_delay_zero_is_accepted() {
416        let mut buffer = SpikeDelayBuffer::new(2, 1);
417        buffer.inject(1, 1.0, 0);
418        let currents = buffer.drain_current_tick();
419        assert!((currents[1] - 1.0).abs() < 1e-6);
420    }
421
422    #[test]
423    fn inject_delay_equal_to_capacity_is_accepted() {
424        let mut buffer = SpikeDelayBuffer::new(2, 1);
425        buffer.inject(0, 1.0, 1);
426        let c0 = buffer.drain_current_tick();
427        assert_eq!(c0[0], 0.0);
428        buffer.advance();
429        let c1 = buffer.drain_current_tick();
430        assert!((c1[0] - 1.0).abs() < 1e-6);
431    }
432
433    #[test]
434    #[should_panic(expected = "delay 2 exceeds max_delay 1")]
435    fn inject_excessive_delay_is_rejected() {
436        let mut buffer = SpikeDelayBuffer::new(2, 1);
437        buffer.inject(1, 1.0, 2);
438    }
439
440    #[test]
441    fn try_inject_excessive_delay_leaves_buffer_unchanged() {
442        let mut buffer = SpikeDelayBuffer::new(2, 1);
443        assert!(buffer.try_inject(1, 1.0, 2).is_err());
444        let currents = buffer.drain_current_tick();
445        assert!(currents.iter().all(|&c| c == 0.0));
446    }
447
448    #[test]
449    #[should_panic(expected = "out of bounds")]
450    fn inject_out_of_range_target_is_rejected() {
451        let mut buffer = SpikeDelayBuffer::new(2, 1);
452        buffer.inject(2, 1.0, 0);
453    }
454
455    #[test]
456    fn try_inject_out_of_range_target_leaves_buffer_unchanged() {
457        let mut buffer = SpikeDelayBuffer::new(2, 1);
458        assert!(buffer.try_inject(2, 1.0, 0).is_err());
459        let currents = buffer.drain_current_tick();
460        assert!(currents.iter().all(|&c| c == 0.0));
461    }
462
463    #[test]
464    fn try_new_rejects_max_delay_plus_one_overflow() {
465        let err = SpikeDelayBuffer::try_new(1, usize::MAX).unwrap_err();
466        let msg = err.to_string();
467        assert!(
468            msg.contains("overflows usize") && msg.contains("ring depth cannot be represented"),
469            "expected overflow error, got {msg}"
470        );
471    }
472
473    #[test]
474    #[should_panic(expected = "overflow")]
475    fn new_rejects_max_delay_plus_one_overflow() {
476        let _ = SpikeDelayBuffer::new(1, usize::MAX);
477    }
478
479    #[test]
480    fn deserialize_rejects_empty_slots() {
481        let json = r#"{"slots":[],"neuron_count":2,"max_delay":1,"current_tick":0}"#;
482        assert!(serde_json::from_str::<SpikeDelayBuffer>(json).is_err());
483    }
484
485    #[test]
486    fn deserialize_rejects_wrong_depth() {
487        let json = r#"{"slots":[[0.0,0.0]],"neuron_count":2,"max_delay":1,"current_tick":0}"#;
488        assert!(serde_json::from_str::<SpikeDelayBuffer>(json).is_err());
489    }
490
491    #[test]
492    fn deserialize_rejects_ragged_slot_widths() {
493        let json = r#"{"slots":[[0.0,0.0],[0.0]],"neuron_count":2,"max_delay":1,"current_tick":0}"#;
494        assert!(serde_json::from_str::<SpikeDelayBuffer>(json).is_err());
495    }
496
497    #[test]
498    fn deserialize_rejects_max_delay_plus_one_overflow() {
499        let json =
500            r#"{"slots":[],"neuron_count":1,"max_delay":18446744073709551615,"current_tick":0}"#;
501        assert!(serde_json::from_str::<SpikeDelayBuffer>(json).is_err());
502        assert!(validate_delay_buffer_shape(&[], 1, usize::MAX).is_err());
503    }
504
505    #[test]
506    fn deserialize_rejects_current_tick_at_u64_max() {
507        let json = r#"{"slots":[[0.0],[0.0]],"neuron_count":1,"max_delay":1,"current_tick":18446744073709551615}"#;
508        assert!(serde_json::from_str::<SpikeDelayBuffer>(json).is_err());
509        assert!(validate_current_tick(u64::MAX, 1).is_err());
510        assert!(validate_current_tick(usize::MAX as u64, 1).is_err());
511        assert!(validate_current_tick(0, 1).is_ok());
512    }
513
514    #[test]
515    fn deserialize_rejects_current_tick_that_advances_to_usize_max() {
516        let tick = usize::MAX as u64 - 1;
517        let json_delay0 =
518            format!(r#"{{"slots":[[0.0]],"neuron_count":1,"max_delay":0,"current_tick":{tick}}}"#);
519        let json_delay1 = format!(
520            r#"{{"slots":[[0.0],[0.0]],"neuron_count":1,"max_delay":1,"current_tick":{tick}}}"#
521        );
522        assert!(serde_json::from_str::<SpikeDelayBuffer>(&json_delay0).is_err());
523        assert!(serde_json::from_str::<SpikeDelayBuffer>(&json_delay1).is_err());
524        assert!(validate_current_tick(tick, 0).is_err());
525        assert!(validate_current_tick(tick, 1).is_err());
526        assert!(validate_current_tick(usize::MAX as u64 - 2, 0).is_ok());
527        assert!(validate_current_tick(usize::MAX as u64 - 2, 1).is_ok());
528    }
529
530    #[test]
531    fn deserialize_rejects_current_tick_that_overflows_inject_arithmetic() {
532        let tick = usize::MAX as u64 - 1;
533        let json = format!(
534            r#"{{"slots":[[0.0],[0.0],[0.0]],"neuron_count":1,"max_delay":2,"current_tick":{tick}}}"#
535        );
536        let err = serde_json::from_str::<SpikeDelayBuffer>(&json).unwrap_err();
537        assert!(
538            err.to_string().contains("overflowing usize indexing"),
539            "unexpected error: {err}"
540        );
541        assert!(validate_current_tick(tick, 2).is_err());
542        // Boundary: current_tick + max_delay == usize::MAX is still safe.
543        assert!(validate_current_tick(usize::MAX as u64 - 2, 2).is_ok());
544    }
545
546    #[test]
547    fn deserialize_accepts_current_tick_at_inject_arithmetic_limit() {
548        let tick = usize::MAX as u64 - 2;
549        let json = format!(
550            r#"{{"slots":[[0.0],[0.0],[0.0]],"neuron_count":1,"max_delay":2,"current_tick":{tick}}}"#
551        );
552        let mut buf: SpikeDelayBuffer = serde_json::from_str(&json).unwrap();
553        buf.inject(0, 1.0, 2);
554        assert_eq!(buf.current_tick(), tick);
555    }
556
557    #[cfg(target_pointer_width = "32")]
558    #[test]
559    fn deserialize_rejects_current_tick_that_wraps_usize() {
560        let json =
561            r#"{"slots":[[0.0],[0.0]],"neuron_count":1,"max_delay":1,"current_tick":4294967296}"#;
562        assert!(serde_json::from_str::<SpikeDelayBuffer>(json).is_err());
563        assert!(validate_current_tick((usize::MAX as u64) + 1, 1).is_err());
564    }
565
566    #[test]
567    fn validate_delay_buffer_shape_rejects_non_finite_currents() {
568        // JSON has no NaN/Infinity literal; a non-JSON serde format could
569        // otherwise smuggle these values into drain_current_tick sums.
570        assert!(validate_delay_buffer_shape(&[vec![0.1], vec![f32::NAN]], 1, 1).is_err());
571        assert!(validate_delay_buffer_shape(&[vec![f32::INFINITY], vec![0.0]], 1, 1).is_err());
572        assert!(validate_delay_buffer_shape(&[vec![0.0], vec![f32::NEG_INFINITY]], 1, 1).is_err());
573        assert!(validate_delay_buffer_shape(&[vec![0.1, -0.0], vec![2.0, 0.0]], 2, 1).is_ok());
574    }
575
576    #[test]
577    fn deserialize_accepts_zero_neuron_and_zero_delay() {
578        let json = r#"{"slots":[[]],"neuron_count":0,"max_delay":0,"current_tick":0}"#;
579        let buf: SpikeDelayBuffer = serde_json::from_str(json).unwrap();
580        assert_eq!(buf.neuron_count(), 0);
581        assert_eq!(buf.max_delay(), 0);
582    }
583
584    #[test]
585    fn serialize_roundtrip_preserves_in_flight_current() {
586        let mut buf = SpikeDelayBuffer::new(2, 1);
587        buf.inject(1, 0.5, 1);
588        let json = serde_json::to_string(&buf).unwrap();
589        let mut restored: SpikeDelayBuffer = serde_json::from_str(&json).unwrap();
590        assert_eq!(restored.current_tick(), 0);
591        assert_eq!(restored.drain_current_tick()[1], 0.0);
592        restored.advance();
593        assert!((restored.drain_current_tick()[1] - 0.5).abs() < 1e-6);
594    }
595
596    #[test]
597    fn drain_into_matches_allocating_drain() {
598        let mut a = SpikeDelayBuffer::new(4, 2);
599        let mut b = SpikeDelayBuffer::new(4, 2);
600        a.inject(1, 0.5, 0);
601        a.inject(3, 1.25, 0);
602        b.inject(1, 0.5, 0);
603        b.inject(3, 1.25, 0);
604
605        let allocated = a.drain_current_tick();
606        let mut reused = vec![42.0; 4];
607        b.drain_current_tick_into(&mut reused).unwrap();
608        assert_eq!(allocated, reused);
609    }
610
611    #[test]
612    fn drain_into_length_mismatch_leaves_buffer_and_output_unchanged() {
613        let mut buf = SpikeDelayBuffer::new(2, 1);
614        buf.inject(0, 1.0, 0);
615        let mut output = [42.0_f32, 42.0, 42.0];
616        let err = buf.drain_current_tick_into(&mut output).unwrap_err();
617        assert!(err.to_string().contains("drain_current_tick_into output"));
618        assert_eq!(output, [42.0, 42.0, 42.0]);
619        let currents = buf.drain_current_tick();
620        assert!((currents[0] - 1.0).abs() < 1e-6);
621    }
622
623    #[test]
624    fn different_delays_different_arrival() {
625        let mut buf = SpikeDelayBuffer::new(4, 5);
626        buf.inject(0, 1.0, 1); // arrives tick 1
627        buf.inject(1, 2.0, 3); // arrives tick 3
628
629        buf.advance(); // tick 1
630        let c1 = buf.drain_current_tick();
631        assert!((c1[0] - 1.0).abs() < 1e-6);
632        assert_eq!(c1[1], 0.0);
633
634        buf.advance(); // tick 2
635        let c2 = buf.drain_current_tick();
636        assert_eq!(c2[0], 0.0);
637        assert_eq!(c2[1], 0.0);
638
639        buf.advance(); // tick 3
640        let c3 = buf.drain_current_tick();
641        assert_eq!(c3[0], 0.0);
642        assert!((c3[1] - 2.0).abs() < 1e-6);
643    }
644}