Skip to main content

sc_neurocore_engine/
neuron.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Neuron Models
8
9//! # Neuron Models
10//!
11//! Fixed-point LIF and Izhikevich neuron models for the v3 engine.
12
13/// Mask and sign-interpret an integer to `width` bits (branchless).
14///
15/// `width` must be in 1..=32. Values outside this range trigger a
16/// debug assertion failure (release builds silently produce garbage).
17#[inline]
18pub fn mask(value: i32, width: u32) -> i16 {
19    assert!(
20        width > 0 && width <= 32,
21        "mask width must be 1..=32, got {width}"
22    );
23    let m = (1_i64 << width) - 1;
24    let v = (value as i64) & m;
25    let shift = 64 - width;
26    ((v << shift) >> shift) as i16
27}
28
29/// Fixed-point leaky-integrate-and-fire neuron state and parameters.
30#[derive(Clone, Debug)]
31pub struct FixedPointLif {
32    /// Membrane potential.
33    pub v: i16,
34    /// Refractory counter in simulation steps.
35    pub refractory_counter: i32,
36    /// Arithmetic data width.
37    pub data_width: u32,
38    /// Fraction bits for fixed-point scaling.
39    pub fraction: u32,
40    /// Resting potential.
41    pub v_rest: i16,
42    /// Reset potential after spike.
43    pub v_reset: i16,
44    /// Spike threshold.
45    pub v_threshold: i16,
46    /// Refractory period length in steps.
47    pub refractory_period: i32,
48}
49
50impl FixedPointLif {
51    /// Construct a fixed-point LIF neuron.
52    pub fn new(
53        data_width: u32,
54        fraction: u32,
55        v_rest: i16,
56        v_reset: i16,
57        v_threshold: i16,
58        refractory_period: i32,
59    ) -> Self {
60        Self {
61            v: v_rest,
62            refractory_counter: 0,
63            data_width,
64            fraction,
65            v_rest,
66            v_reset,
67            v_threshold,
68            refractory_period,
69        }
70    }
71
72    /// Advance one simulation step.
73    ///
74    /// Returns `(spike, membrane_voltage)`.
75    #[allow(non_snake_case)]
76    pub fn step(&mut self, leak_k: i16, gain_k: i16, i_t: i16, noise_in: i16) -> (i32, i16) {
77        let w = self.data_width;
78
79        // Refractory: check previous step's counter before any fire logic.
80        if self.refractory_counter > 0 {
81            self.refractory_counter -= 1;
82            self.v = self.v_rest;
83            return (0, mask(self.v_rest as i32, w));
84        }
85
86        let diff = mask((self.v_rest as i32) - (self.v as i32), 2 * w) as i32;
87        let dv_leak = mask((diff * (leak_k as i32)) >> self.fraction, self.data_width);
88        let dv_in = mask(
89            ((i_t as i32) * (gain_k as i32)) >> self.fraction,
90            self.data_width,
91        );
92
93        let v_next = mask(
94            (self.v as i32) + (dv_leak as i32) + (dv_in as i32) + (noise_in as i32),
95            self.data_width,
96        );
97
98        if v_next >= self.v_threshold {
99            self.v = self.v_reset;
100            self.refractory_counter = self.refractory_period;
101            (1, mask(self.v_reset as i32, w))
102        } else {
103            self.v = v_next;
104            (0, mask(v_next as i32, w))
105        }
106    }
107
108    /// Reset internal state to resting potential.
109    pub fn reset(&mut self) {
110        self.v = self.v_rest;
111        self.refractory_counter = 0;
112    }
113}
114
115/// Izhikevich neuron (floating-point).
116///
117/// Standard model from IEEE TNN 14(6), 2003:
118///   v' = 0.04*v² + 5*v + 140 - u + I
119///   u' = a*(b*v - u)
120///   if v >= 30: v ← c, u ← u + d
121#[derive(Clone, Debug)]
122pub struct Izhikevich {
123    pub v: f64,
124    pub u: f64,
125    pub a: f64,
126    pub b: f64,
127    pub c: f64,
128    pub d: f64,
129    pub dt: f64,
130}
131
132impl Izhikevich {
133    /// Regular spiking defaults: a=0.02, b=0.2, c=-65, d=8, dt=1.0.
134    pub fn new(a: f64, b: f64, c: f64, d: f64, dt: f64) -> Self {
135        Self {
136            v: c,
137            u: b * c,
138            a,
139            b,
140            c,
141            d,
142            dt,
143        }
144    }
145
146    /// Regular spiking preset.
147    pub fn regular_spiking() -> Self {
148        Self::new(0.02, 0.2, -65.0, 8.0, 1.0)
149    }
150
151    /// Advance one step. Returns 1 on spike, 0 otherwise.
152    pub fn step(&mut self, current: f64) -> i32 {
153        // Two half-steps for numerical stability on 0.04v² term.
154        let half = self.dt * 0.5;
155        for _ in 0..2 {
156            let dv = (0.04 * self.v * self.v + 5.0 * self.v + 140.0 - self.u + current) * half;
157            let du = (self.a * (self.b * self.v - self.u)) * half;
158            self.v += dv;
159            self.u += du;
160        }
161
162        if self.v >= 30.0 {
163            self.v = self.c;
164            self.u += self.d;
165            1
166        } else {
167            0
168        }
169    }
170
171    /// Reset to initial state.
172    pub fn reset(&mut self) {
173        self.v = self.c;
174        self.u = self.b * self.c;
175    }
176}
177
178/// Sliding-window bitstream probability estimator.
179///
180/// Mirrors Python's `BitstreamAverager`.
181#[derive(Clone, Debug)]
182pub struct BitstreamAverager {
183    buffer: Vec<u8>,
184    index: usize,
185    filled: bool,
186    running_sum: u64,
187}
188
189impl BitstreamAverager {
190    pub fn new(window: usize) -> Self {
191        assert!(window > 0, "window must be > 0");
192        Self {
193            buffer: vec![0; window],
194            index: 0,
195            filled: false,
196            running_sum: 0,
197        }
198    }
199
200    pub fn push(&mut self, bit: u8) {
201        debug_assert!(bit <= 1, "bit must be 0 or 1");
202        let old = self.buffer[self.index];
203        self.buffer[self.index] = bit;
204
205        if self.filled {
206            self.running_sum = self.running_sum - old as u64 + bit as u64;
207        } else {
208            self.running_sum += bit as u64;
209        }
210
211        self.index += 1;
212        if self.index == self.buffer.len() {
213            self.index = 0;
214            self.filled = true;
215        }
216    }
217
218    pub fn estimate(&self) -> f64 {
219        if !self.filled {
220            if self.index == 0 {
221                return 0.0;
222            }
223            return self.running_sum as f64 / self.index as f64;
224        }
225        self.running_sum as f64 / self.buffer.len() as f64
226    }
227
228    pub fn reset(&mut self) {
229        self.buffer.fill(0);
230        self.index = 0;
231        self.filled = false;
232        self.running_sum = 0;
233    }
234
235    pub fn window(&self) -> usize {
236        self.buffer.len()
237    }
238}
239
240/// Homeostatic LIF neuron with adaptive threshold.
241///
242/// Threshold adapts via EMA of spike rate toward a target setpoint.
243/// Turrigiano, Cold Spring Harb Perspect Biol 4:a005736, 2012.
244#[derive(Clone, Debug)]
245pub struct HomeostaticLif {
246    pub v: f64,
247    pub v_threshold: f64,
248    pub v_rest: f64,
249    pub v_reset: f64,
250    pub rate_trace: f64,
251    pub target_rate: f64,
252    pub adaptation_rate: f64,
253    pub trace_decay: f64,
254    initial_threshold: f64,
255}
256
257impl HomeostaticLif {
258    pub fn new(target_rate: f64, adaptation_rate: f64, trace_decay: f64) -> Self {
259        Self {
260            v: 0.0,
261            v_threshold: 1.0,
262            v_rest: 0.0,
263            v_reset: 0.0,
264            rate_trace: 0.0,
265            target_rate,
266            adaptation_rate,
267            trace_decay,
268            initial_threshold: 1.0,
269        }
270    }
271
272    pub fn with_defaults() -> Self {
273        Self::new(0.1, 0.01, 0.95)
274    }
275
276    /// LIF step with threshold adaptation. Returns 1 on spike.
277    pub fn step(&mut self, current: f64) -> i32 {
278        // Leak-integrate
279        let tau = 20.0;
280        self.v += (-(self.v - self.v_rest) + current) / tau;
281
282        let spike = if self.v >= self.v_threshold {
283            self.v = self.v_reset;
284            1
285        } else {
286            0
287        };
288
289        // EMA spike rate tracking
290        self.rate_trace =
291            self.rate_trace * self.trace_decay + spike as f64 * (1.0 - self.trace_decay);
292
293        // Threshold adaptation
294        let error = self.rate_trace - self.target_rate;
295        self.v_threshold += self.adaptation_rate * error;
296        self.v_threshold = self.v_threshold.clamp(0.1, self.initial_threshold * 10.0);
297
298        spike
299    }
300
301    pub fn reset(&mut self) {
302        self.v = self.v_rest;
303        self.rate_trace = 0.0;
304        self.v_threshold = self.initial_threshold;
305    }
306}
307
308/// XOR-nonlinearity dendritic neuron.
309///
310/// Koch, Biophysics of Computation, 1999, Ch. 12.
311/// Output = 1 if (d1 + d2 - 2*d1*d2) > threshold.
312#[derive(Clone, Debug)]
313pub struct DendriticNeuron {
314    pub threshold: f64,
315    last_current: f64,
316}
317
318impl DendriticNeuron {
319    pub fn new(threshold: f64) -> Self {
320        Self {
321            threshold,
322            last_current: 0.0,
323        }
324    }
325
326    pub fn with_defaults() -> Self {
327        Self::new(0.5)
328    }
329
330    pub fn step(&mut self, input_a: f64, input_b: f64) -> i32 {
331        self.last_current = input_a + input_b - 2.0 * input_a * input_b;
332        if self.last_current > self.threshold {
333            1
334        } else {
335            0
336        }
337    }
338
339    pub fn reset(&mut self) {
340        self.last_current = 0.0;
341    }
342}
343
344/// Adaptive Exponential IF neuron. Brette & Gerstner 2005.
345/// PyO3 wrapper: `pyo3_neurons::PyAdExNeuron`
346#[derive(Clone, Debug)]
347pub struct AdExNeuron {
348    pub v: f64,
349    pub w: f64,
350    pub v_rest: f64,
351    pub v_reset: f64,
352    pub v_threshold: f64,
353    pub v_rh: f64,
354    pub delta_t: f64,
355    pub tau: f64,
356    pub tau_w: f64,
357    pub a: f64,
358    pub b: f64,
359    pub c_m: f64,
360    pub dt: f64,
361}
362
363impl Default for AdExNeuron {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369impl AdExNeuron {
370    pub fn new() -> Self {
371        Self {
372            v: -65.0,
373            w: 0.0,
374            v_rest: -65.0,
375            v_reset: -68.0,
376            v_threshold: -50.0,
377            v_rh: -55.0,
378            delta_t: 2.0,
379            tau: 20.0,
380            tau_w: 100.0,
381            a: 0.5,
382            b: 7.0,
383            c_m: 200.0,
384            dt: 0.1,
385        }
386    }
387
388    pub fn step(&mut self, current: f64) -> i32 {
389        // Brette & Gerstner 2005: C dV/dt = -g_L(V-E_L) + g_L ΔT exp((V-V_T)/ΔT) - w + I
390        let exp_arg = ((self.v - self.v_rh) / self.delta_t).clamp(-20.0, 20.0);
391        let exp_term = self.delta_t * exp_arg.exp();
392        let dv = ((-(self.v - self.v_rest) + exp_term) / self.tau + (-self.w + current) / self.c_m)
393            * self.dt;
394        let dw = (self.a * (self.v - self.v_rest) - self.w) / self.tau_w * self.dt;
395        self.v += dv;
396        self.w += dw;
397
398        if self.v >= self.v_threshold {
399            self.v = self.v_reset;
400            self.w += self.b;
401            1
402        } else {
403            0
404        }
405    }
406
407    pub fn reset(&mut self) {
408        self.v = self.v_rest;
409        self.w = 0.0;
410    }
411}
412
413/// Exponential IF (no adaptation). Fourcaud-Trocmé et al. 2003.
414#[derive(Clone, Debug)]
415pub struct ExpIfNeuron {
416    pub v: f64,
417    pub v_rest: f64,
418    pub v_reset: f64,
419    pub v_threshold: f64,
420    pub v_rh: f64,
421    pub delta_t: f64,
422    pub tau: f64,
423    pub dt: f64,
424    /// Precomputed 1.0 / delta_t.
425    pub inv_delta_t: f64,
426    /// Precomputed dt / tau.
427    pub dt_div_tau: f64,
428}
429
430impl Default for ExpIfNeuron {
431    fn default() -> Self {
432        Self::new()
433    }
434}
435
436impl ExpIfNeuron {
437    pub fn new() -> Self {
438        Self {
439            v: -65.0,
440            v_rest: -65.0,
441            v_reset: -68.0,
442            v_threshold: -50.0,
443            v_rh: -55.0,
444            delta_t: 2.0,
445            tau: 20.0,
446            dt: 0.1,
447            inv_delta_t: 1.0 / 2.0,
448            dt_div_tau: 0.1 / 20.0,
449        }
450    }
451
452    pub fn step(&mut self, current: f64) -> i32 {
453        if !self.v.is_finite()
454            || !current.is_finite()
455            || !self.v_rest.is_finite()
456            || !self.v_reset.is_finite()
457            || !self.v_threshold.is_finite()
458            || !self.v_rh.is_finite()
459            || !self.delta_t.is_finite()
460            || !self.tau.is_finite()
461            || !self.dt.is_finite()
462            || self.delta_t <= 0.0
463            || self.tau <= 0.0
464            || self.dt <= 0.0
465        {
466            return 0;
467        }
468
469        self.inv_delta_t = 1.0 / self.delta_t;
470        self.dt_div_tau = self.dt / self.tau;
471        let k1 = self.rhs(self.v, current);
472        let k2 = self.rhs(self.v + 0.5 * self.dt * k1, current);
473        let k3 = self.rhs(self.v + 0.5 * self.dt * k2, current);
474        let k4 = self.rhs(self.v + self.dt * k3, current);
475        let next_v = self.v + self.dt * (k1 + 2.0 * k2 + 2.0 * k3 + k4) / 6.0;
476        if !k1.is_finite()
477            || !k2.is_finite()
478            || !k3.is_finite()
479            || !k4.is_finite()
480            || !next_v.is_finite()
481        {
482            return 0;
483        }
484        self.v = next_v;
485
486        if self.v >= self.v_threshold {
487            self.v = self.v_reset;
488            1
489        } else {
490            0
491        }
492    }
493
494    pub fn reset(&mut self) {
495        self.v = self.v_rest;
496    }
497
498    fn rhs(&self, v: f64, current: f64) -> f64 {
499        let exp_arg = ((v - self.v_rh) * self.inv_delta_t).clamp(-20.0, 20.0);
500        let exp_term = self.delta_t * exp_arg.exp();
501        (-(v - self.v_rest) + exp_term + current) / self.tau
502    }
503}
504
505/// Lapicque 1907 — classical RC integrate-and-fire.
506#[derive(Clone, Debug)]
507pub struct LapicqueNeuron {
508    pub v: f64,
509    pub v_rest: f64,
510    pub v_reset: f64,
511    pub v_threshold: f64,
512    pub tau: f64,
513    pub resistance: f64,
514    pub dt: f64,
515}
516
517impl LapicqueNeuron {
518    pub fn new(tau: f64, resistance: f64, threshold: f64, dt: f64) -> Self {
519        Self {
520            v: 0.0,
521            v_rest: 0.0,
522            v_reset: 0.0,
523            v_threshold: threshold,
524            tau,
525            resistance,
526            dt,
527        }
528    }
529
530    pub fn step(&mut self, current: f64) -> i32 {
531        if !self.v.is_finite()
532            || !self.v_rest.is_finite()
533            || !self.v_reset.is_finite()
534            || !self.v_threshold.is_finite()
535            || self.v_threshold <= self.v_rest
536            || self.v_threshold <= self.v_reset
537            || self.v >= self.v_threshold
538            || !self.tau.is_finite()
539            || self.tau <= 0.0
540            || !self.resistance.is_finite()
541            || self.resistance <= 0.0
542            || !self.dt.is_finite()
543            || self.dt <= 0.0
544            || !current.is_finite()
545        {
546            return 0;
547        }
548
549        let v_inf = self.v_rest + self.resistance * current;
550        let decay = (-self.dt / self.tau).exp();
551        let next_v = v_inf + (self.v - v_inf) * decay;
552        if !v_inf.is_finite() || !decay.is_finite() || !next_v.is_finite() {
553            return 0;
554        }
555        self.v = next_v;
556
557        if self.v >= self.v_threshold {
558            self.v = self.v_reset;
559            1
560        } else {
561            0
562        }
563    }
564
565    pub fn reset(&mut self) {
566        self.v = self.v_rest;
567    }
568}
569
570#[cfg(test)]
571mod tests {
572
573    #[test]
574    fn test_exp_if_optimisation_parity() {
575        let mut n = ExpIfNeuron::new();
576        n.v = -60.0;
577        let current = 10.0;
578
579        let rhs = |v: f64| {
580            let exp_arg = ((v - n.v_rh) / n.delta_t).clamp(-20.0, 20.0);
581            (-(v - n.v_rest) + n.delta_t * exp_arg.exp() + current) / n.tau
582        };
583        let k1 = rhs(n.v);
584        let k2 = rhs(n.v + 0.5 * n.dt * k1);
585        let k3 = rhs(n.v + 0.5 * n.dt * k2);
586        let k4 = rhs(n.v + n.dt * k3);
587        let expected_dv = n.dt * (k1 + 2.0 * k2 + 2.0 * k3 + k4) / 6.0;
588
589        n.step(current);
590        let got_dv = n.v - (-60.0); // Simple check since we only did one step
591
592        // Use a small epsilon for float parity.
593        assert!(
594            (got_dv - expected_dv).abs() < 1e-12,
595            "Logic mismatch in ExpIfNeuron: got {}, expected {}",
596            got_dv,
597            expected_dv
598        );
599    }
600
601    use super::{
602        mask, AdExNeuron, BitstreamAverager, DendriticNeuron, ExpIfNeuron, FixedPointLif,
603        HomeostaticLif, Izhikevich, LapicqueNeuron,
604    };
605
606    #[test]
607    fn mask_branchless_matches_original() {
608        for &width in &[16_u32, 32] {
609            for value in [
610                -32768_i32,
611                -1,
612                0,
613                1,
614                32767,
615                65535,
616                -65536,
617                i16::MAX as i32,
618                i16::MIN as i32,
619            ] {
620                let result = mask(value, width);
621
622                let m = (1_i64 << width) - 1;
623                let mut v = (value as i64) & m;
624                if v >= (1_i64 << (width - 1)) {
625                    v -= 1_i64 << width;
626                }
627                let expected = if width >= 32 {
628                    v as i32 as i16
629                } else {
630                    v as i16
631                };
632
633                assert_eq!(
634                    result, expected,
635                    "mask({value}, {width}): got {result}, expected {expected}"
636                );
637            }
638        }
639    }
640
641    #[test]
642    fn lif_fires_with_refractory_period() {
643        // Q8.8: threshold=1.0 → 256, matching Python default
644        let mut n = FixedPointLif::new(16, 8, 0, 0, 256, 2);
645        let mut spikes = Vec::new();
646        for _ in 0..30 {
647            let (s, _) = n.step(1, 256, 50, 0);
648            spikes.push(s);
649        }
650        let total: i32 = spikes.iter().sum();
651        assert!(total > 0, "neuron must fire with refractory_period=2");
652        // Refractory gap: after a spike, next 2 steps must be silent.
653        for (i, &s) in spikes.iter().enumerate() {
654            if s == 1 && i + 2 < spikes.len() {
655                assert_eq!(spikes[i + 1], 0, "step {} should be refractory", i + 1);
656                assert_eq!(spikes[i + 2], 0, "step {} should be refractory", i + 2);
657            }
658        }
659    }
660
661    #[test]
662    fn lif_fires_without_refractory() {
663        let mut n = FixedPointLif::new(16, 8, 0, 0, 256, 0);
664        let mut total = 0;
665        for _ in 0..20 {
666            let (s, _) = n.step(1, 256, 50, 0);
667            total += s;
668        }
669        assert!(total > 0, "neuron must fire with refractory_period=0");
670    }
671
672    // ── Izhikevich tests ──────────────────────────────────────────
673
674    #[test]
675    fn izhikevich_regular_spiking_fires() {
676        let mut n = Izhikevich::regular_spiking();
677        let mut total = 0;
678        for _ in 0..100 {
679            total += n.step(10.0);
680        }
681        assert!(total > 0, "RS neuron must fire with I=10");
682    }
683
684    #[test]
685    fn izhikevich_no_spike_without_input() {
686        let mut n = Izhikevich::regular_spiking();
687        let mut total = 0;
688        for _ in 0..100 {
689            total += n.step(0.0);
690        }
691        assert_eq!(total, 0, "no spikes without input");
692    }
693
694    #[test]
695    fn izhikevich_reset_clears_state() {
696        let mut n = Izhikevich::regular_spiking();
697        for _ in 0..50 {
698            n.step(10.0);
699        }
700        n.reset();
701        assert_eq!(n.v, n.c);
702        assert!((n.u - n.b * n.c).abs() < 1e-12);
703    }
704
705    #[test]
706    fn izhikevich_chattering_fires_more() {
707        // Chattering: a=0.02, b=0.2, c=-50, d=2
708        let mut ch = Izhikevich::new(0.02, 0.2, -50.0, 2.0, 1.0);
709        let mut rs = Izhikevich::regular_spiking();
710        let mut ch_spikes = 0;
711        let mut rs_spikes = 0;
712        for _ in 0..200 {
713            ch_spikes += ch.step(10.0);
714            rs_spikes += rs.step(10.0);
715        }
716        assert!(
717            ch_spikes > rs_spikes,
718            "chattering ({ch_spikes}) should fire more than RS ({rs_spikes})"
719        );
720    }
721
722    // ── BitstreamAverager tests ───────────────────────────────────
723
724    #[test]
725    fn averager_all_ones() {
726        let mut avg = BitstreamAverager::new(100);
727        for _ in 0..100 {
728            avg.push(1);
729        }
730        assert!((avg.estimate() - 1.0).abs() < 1e-12);
731    }
732
733    #[test]
734    fn averager_all_zeros() {
735        let mut avg = BitstreamAverager::new(50);
736        for _ in 0..50 {
737            avg.push(0);
738        }
739        assert!(avg.estimate().abs() < 1e-12);
740    }
741
742    #[test]
743    fn averager_half() {
744        let mut avg = BitstreamAverager::new(100);
745        for i in 0..100 {
746            avg.push((i % 2) as u8);
747        }
748        assert!((avg.estimate() - 0.5).abs() < 1e-12);
749    }
750
751    #[test]
752    fn averager_sliding_window() {
753        let mut avg = BitstreamAverager::new(4);
754        // Fill: [1, 1, 0, 0] → 0.5
755        for &b in &[1_u8, 1, 0, 0] {
756            avg.push(b);
757        }
758        assert!((avg.estimate() - 0.5).abs() < 1e-12);
759        // Push 1 → [1, 1, 0, 1] (oldest 1 replaced by 1) → wait
760        // Actually buffer is circular: index=0, push 1 replaces buffer[0]=1 with 1 → still 0.5
761        avg.push(1);
762        // Buffer: [1, 1, 0, 0] → index wraps to 0, push 1 at index 0: [1, 1, 0, 0] → [1, 1, 0, 0] no wait
763        // filled=true after first wrap. push(1) at index 0: old=1, new=1, sum stays 2 → 0.5
764        assert!((avg.estimate() - 0.5).abs() < 1e-12);
765        // Push 1 at index 1: old=1, new=1 → still 0.5
766        avg.push(1);
767        assert!((avg.estimate() - 0.5).abs() < 1e-12);
768        // Push 1 at index 2: old=0, new=1 → sum=3 → 0.75
769        avg.push(1);
770        assert!((avg.estimate() - 0.75).abs() < 1e-12);
771    }
772
773    #[test]
774    fn averager_partial_fill() {
775        let mut avg = BitstreamAverager::new(100);
776        avg.push(1);
777        avg.push(0);
778        assert!((avg.estimate() - 0.5).abs() < 1e-12);
779    }
780
781    #[test]
782    fn averager_empty_returns_zero() {
783        let avg = BitstreamAverager::new(10);
784        assert!(avg.estimate().abs() < 1e-12);
785    }
786
787    // ── HomeostaticLif tests ──────────────────────────────────────
788
789    #[test]
790    fn homeostatic_fires_with_strong_input() {
791        let mut n = HomeostaticLif::with_defaults();
792        let mut total = 0;
793        for _ in 0..200 {
794            total += n.step(25.0);
795        }
796        assert!(total > 0, "must fire with strong input");
797    }
798
799    #[test]
800    fn homeostatic_threshold_adapts() {
801        let mut n = HomeostaticLif::with_defaults();
802        let initial = n.v_threshold;
803        for _ in 0..500 {
804            n.step(25.0);
805        }
806        assert!(
807            (n.v_threshold - initial).abs() > 1e-6,
808            "threshold must adapt"
809        );
810    }
811
812    #[test]
813    fn homeostatic_no_fire_without_input() {
814        let mut n = HomeostaticLif::with_defaults();
815        let mut total = 0;
816        for _ in 0..100 {
817            total += n.step(0.0);
818        }
819        assert_eq!(total, 0);
820    }
821
822    #[test]
823    fn homeostatic_threshold_bounded() {
824        let mut n = HomeostaticLif::with_defaults();
825        for _ in 0..10000 {
826            n.step(50.0);
827        }
828        assert!(n.v_threshold >= 0.1);
829        assert!(n.v_threshold <= 10.0);
830    }
831
832    // ── DendriticNeuron tests ─────────────────────────────────────
833
834    #[test]
835    fn dendritic_xor_truth_table() {
836        let mut n = DendriticNeuron::new(0.5);
837        assert_eq!(n.step(0.0, 0.0), 0); // 0+0-0 = 0
838        assert_eq!(n.step(1.0, 0.0), 1); // 1+0-0 = 1
839        assert_eq!(n.step(0.0, 1.0), 1); // 0+1-0 = 1
840        assert_eq!(n.step(1.0, 1.0), 0); // 1+1-2 = 0
841    }
842
843    #[test]
844    fn dendritic_subthreshold() {
845        let mut n = DendriticNeuron::new(0.5);
846        assert_eq!(n.step(0.2, 0.1), 0);
847    }
848
849    #[test]
850    fn dendritic_reset() {
851        let mut n = DendriticNeuron::with_defaults();
852        n.step(1.0, 0.0);
853        n.reset();
854        assert!((n.last_current).abs() < 1e-12);
855    }
856
857    #[test]
858    fn averager_reset() {
859        let mut avg = BitstreamAverager::new(10);
860        for _ in 0..10 {
861            avg.push(1);
862        }
863        avg.reset();
864        assert!(avg.estimate().abs() < 1e-12);
865    }
866
867    // ── AdEx tests ────────────────────────────────────────────────
868
869    #[test]
870    fn adex_fires_with_input() {
871        let mut n = AdExNeuron::new();
872        let mut total = 0;
873        for _ in 0..2000 {
874            total += n.step(500.0);
875        }
876        assert!(total > 0, "AdEx must fire with strong input");
877    }
878
879    #[test]
880    fn adex_adaptation_reduces_rate() {
881        let mut n = AdExNeuron::new();
882        let first_100: i32 = (0..1000).map(|_| n.step(400.0)).sum();
883        let next_100: i32 = (0..1000).map(|_| n.step(400.0)).sum();
884        // Adaptation should reduce firing over time (w grows)
885        assert!(
886            next_100 <= first_100 + 5,
887            "adaptation should not increase rate: first={first_100}, next={next_100}"
888        );
889    }
890
891    // ── ExpIF tests ───────────────────────────────────────────────
892
893    #[test]
894    fn expif_fires() {
895        let mut n = ExpIfNeuron::new();
896        let mut total = 0;
897        for _ in 0..2000 {
898            total += n.step(500.0);
899        }
900        assert!(total > 0, "ExpIF must fire");
901    }
902
903    #[test]
904    fn expif_no_fire_without_input() {
905        let mut n = ExpIfNeuron::new();
906        let total: i32 = (0..500).map(|_| n.step(0.0)).sum();
907        assert_eq!(total, 0);
908    }
909
910    #[test]
911    fn expif_matches_rk4_reference() {
912        let mut n = ExpIfNeuron::new();
913        n.v = -60.0;
914        n.dt = 0.25;
915        n.tau = 20.0;
916        let rhs = |v: f64, s: &ExpIfNeuron, current: f64| {
917            let exp_arg = ((v - s.v_rh) / s.delta_t).clamp(-20.0, 20.0);
918            (-(v - s.v_rest) + s.delta_t * exp_arg.exp() + current) / s.tau
919        };
920        let current = 12.0;
921        let k1 = rhs(n.v, &n, current);
922        let k2 = rhs(n.v + 0.5 * n.dt * k1, &n, current);
923        let k3 = rhs(n.v + 0.5 * n.dt * k2, &n, current);
924        let k4 = rhs(n.v + n.dt * k3, &n, current);
925        let expected = n.v + n.dt * (k1 + 2.0 * k2 + 2.0 * k3 + k4) / 6.0;
926
927        assert_eq!(n.step(current), 0);
928        assert!((n.v - expected).abs() < 1e-12);
929    }
930
931    // ── Lapicque tests ────────────────────────────────────────────
932
933    #[test]
934    fn lapicque_fires() {
935        let mut n = LapicqueNeuron::new(20.0, 1.0, 1.0, 1.0);
936        let mut total = 0;
937        for _ in 0..200 {
938            total += n.step(5.0);
939        }
940        assert!(total > 0, "Lapicque must fire with sustained input");
941    }
942
943    #[test]
944    fn lapicque_reset() {
945        let mut n = LapicqueNeuron::new(20.0, 1.0, 1.0, 1.0);
946        for _ in 0..50 {
947            n.step(5.0);
948        }
949        n.reset();
950        assert!((n.v).abs() < 1e-12);
951    }
952
953    #[test]
954    fn lapicque_exact_flow_matches_closed_form() {
955        let mut n = LapicqueNeuron::new(20.0, 1.0, 1.0, 5.0);
956        n.v = 0.25;
957        let current = 0.5;
958        let v0 = n.v;
959        let v_inf = n.v_rest + n.resistance * current;
960        let euler = v0 + (-(v0 - n.v_rest) + n.resistance * current) / n.tau * n.dt;
961        let expected = v_inf + (v0 - v_inf) * (-n.dt / n.tau).exp();
962
963        assert_eq!(n.step(current), 0);
964        assert!((n.v - expected).abs() < 1e-15);
965        assert!((n.v - euler).abs() > 1e-4);
966    }
967
968    // ── AdEx coverage tests ────────────────────────────────────────
969
970    #[test]
971    fn adex_no_fire_without_input() {
972        let mut n = AdExNeuron::new();
973        let total: i32 = (0..1000).map(|_| n.step(0.0)).sum();
974        assert_eq!(total, 0);
975    }
976
977    #[test]
978    fn adex_negative_current_no_fire() {
979        let mut n = AdExNeuron::new();
980        let total: i32 = (0..500).map(|_| n.step(-100.0)).sum();
981        assert_eq!(total, 0, "negative current must not cause spikes");
982    }
983
984    #[test]
985    fn adex_reset_roundtrip() {
986        let mut n = AdExNeuron::new();
987        for _ in 0..200 {
988            n.step(500.0);
989        }
990        assert!(n.w > 0.0, "w must grow during spiking");
991        n.reset();
992        assert_eq!(n.v, n.v_rest);
993        assert_eq!(n.w, 0.0);
994        // Post-reset: should behave identically to fresh
995        let mut fresh = AdExNeuron::new();
996        let r1: i32 = (0..100).map(|_| n.step(500.0)).sum();
997        let r2: i32 = (0..100).map(|_| fresh.step(500.0)).sum();
998        assert_eq!(r1, r2, "reset neuron must match fresh neuron");
999    }
1000
1001    #[test]
1002    fn adex_voltage_bounded() {
1003        let mut n = AdExNeuron::new();
1004        for _ in 0..5000 {
1005            n.step(1000.0);
1006        }
1007        assert!(n.v.is_finite(), "voltage must stay finite");
1008        assert!(n.w.is_finite(), "adaptation must stay finite");
1009    }
1010
1011    #[test]
1012    fn adex_pipeline_sustained_spiking() {
1013        let mut n = AdExNeuron::new();
1014        let spikes: i32 = (0..10000).map(|_| n.step(500.0)).sum();
1015        assert!(
1016            spikes > 100,
1017            "sustained input should produce many spikes: got {spikes}"
1018        );
1019        assert!(n.v.is_finite());
1020    }
1021
1022    #[test]
1023    fn adex_performance_10k_steps() {
1024        let mut n = AdExNeuron::new();
1025        let start = std::time::Instant::now();
1026        for _ in 0..10_000 {
1027            n.step(500.0);
1028        }
1029        let elapsed = start.elapsed();
1030        assert!(
1031            elapsed.as_millis() < 50,
1032            "10k steps took too long: {:?}",
1033            elapsed
1034        );
1035    }
1036
1037    // ── ExpIF coverage tests ───────────────────────────────────────
1038
1039    #[test]
1040    fn expif_negative_current_no_fire() {
1041        let mut n = ExpIfNeuron::new();
1042        let total: i32 = (0..500).map(|_| n.step(-100.0)).sum();
1043        assert_eq!(total, 0);
1044    }
1045
1046    #[test]
1047    fn expif_reset_roundtrip() {
1048        let mut n = ExpIfNeuron::new();
1049        for _ in 0..200 {
1050            n.step(500.0);
1051        }
1052        n.reset();
1053        assert_eq!(n.v, n.v_rest);
1054        let mut fresh = ExpIfNeuron::new();
1055        let r1: i32 = (0..100).map(|_| n.step(500.0)).sum();
1056        let r2: i32 = (0..100).map(|_| fresh.step(500.0)).sum();
1057        assert_eq!(r1, r2);
1058    }
1059
1060    #[test]
1061    fn expif_voltage_bounded() {
1062        let mut n = ExpIfNeuron::new();
1063        for _ in 0..5000 {
1064            n.step(1000.0);
1065        }
1066        assert!(n.v.is_finite());
1067    }
1068
1069    #[test]
1070    fn expif_fires_more_than_adex() {
1071        // ExpIF has no adaptation, should fire at least as much as AdEx
1072        let mut eif = ExpIfNeuron::new();
1073        let mut adex = AdExNeuron::new();
1074        let eif_spikes: i32 = (0..5000).map(|_| eif.step(500.0)).sum();
1075        let adex_spikes: i32 = (0..5000).map(|_| adex.step(500.0)).sum();
1076        assert!(
1077            eif_spikes >= adex_spikes,
1078            "ExpIF ({eif_spikes}) should fire >= AdEx ({adex_spikes}) due to no adaptation"
1079        );
1080    }
1081
1082    #[test]
1083    fn expif_performance_10k_steps() {
1084        let mut n = ExpIfNeuron::new();
1085        let start = std::time::Instant::now();
1086        for _ in 0..10_000 {
1087            n.step(500.0);
1088        }
1089        let elapsed = start.elapsed();
1090        assert!(
1091            elapsed.as_millis() < 50,
1092            "10k steps took too long: {:?}",
1093            elapsed
1094        );
1095    }
1096
1097    // ── Lapicque coverage tests ────────────────────────────────────
1098
1099    #[test]
1100    fn lapicque_no_fire_without_input() {
1101        let mut n = LapicqueNeuron::new(20.0, 1.0, 1.0, 1.0);
1102        let total: i32 = (0..500).map(|_| n.step(0.0)).sum();
1103        assert_eq!(total, 0);
1104    }
1105
1106    #[test]
1107    fn lapicque_negative_current_no_fire() {
1108        let mut n = LapicqueNeuron::new(20.0, 1.0, 1.0, 1.0);
1109        let total: i32 = (0..500).map(|_| n.step(-5.0)).sum();
1110        assert_eq!(total, 0);
1111    }
1112
1113    #[test]
1114    fn lapicque_invalid_state_does_not_mutate() {
1115        let mut n = LapicqueNeuron::new(20.0, 1.0, 1.0, 1.0);
1116        n.v = 0.25;
1117        n.tau = 0.0;
1118        assert_eq!(n.step(1.0), 0);
1119        assert_eq!(n.v, 0.25);
1120    }
1121
1122    #[test]
1123    fn lapicque_reset_roundtrip() {
1124        let mut n = LapicqueNeuron::new(20.0, 1.0, 1.0, 1.0);
1125        for _ in 0..100 {
1126            n.step(5.0);
1127        }
1128        n.reset();
1129        assert_eq!(n.v, n.v_rest);
1130        let mut fresh = LapicqueNeuron::new(20.0, 1.0, 1.0, 1.0);
1131        let r1: i32 = (0..100).map(|_| n.step(5.0)).sum();
1132        let r2: i32 = (0..100).map(|_| fresh.step(5.0)).sum();
1133        assert_eq!(r1, r2);
1134    }
1135
1136    #[test]
1137    fn lapicque_voltage_bounded() {
1138        let mut n = LapicqueNeuron::new(20.0, 1.0, 1.0, 1.0);
1139        for _ in 0..5000 {
1140            n.step(100.0);
1141        }
1142        assert!(n.v.is_finite());
1143    }
1144
1145    #[test]
1146    fn lapicque_higher_resistance_fires_faster() {
1147        let mut lo = LapicqueNeuron::new(20.0, 0.5, 1.0, 1.0);
1148        let mut hi = LapicqueNeuron::new(20.0, 2.0, 1.0, 1.0);
1149        let lo_spikes: i32 = (0..200).map(|_| lo.step(1.0)).sum();
1150        let hi_spikes: i32 = (0..200).map(|_| hi.step(1.0)).sum();
1151        assert!(
1152            hi_spikes >= lo_spikes,
1153            "higher R ({hi_spikes}) should fire >= lower R ({lo_spikes})"
1154        );
1155    }
1156
1157    #[test]
1158    fn lapicque_performance_10k_steps() {
1159        let mut n = LapicqueNeuron::new(20.0, 1.0, 1.0, 1.0);
1160        let start = std::time::Instant::now();
1161        for _ in 0..10_000 {
1162            n.step(5.0);
1163        }
1164        let elapsed = start.elapsed();
1165        assert!(
1166            elapsed.as_millis() < 50,
1167            "10k steps took too long: {:?}",
1168            elapsed
1169        );
1170    }
1171
1172    #[test]
1173    fn lapicque_pipeline_sustained_spiking() {
1174        let mut n = LapicqueNeuron::new(20.0, 1.0, 1.0, 1.0);
1175        let spikes: i32 = (0..10000).map(|_| n.step(5.0)).sum();
1176        assert!(
1177            spikes > 100,
1178            "sustained input should produce many spikes: got {spikes}"
1179        );
1180    }
1181}