Skip to main content

sc_neurocore_engine/neurons/
maps.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 — Discrete map neuron models
8
9//! Discrete map neuron models.
10
11/// Chialvo 1995 — 2D discrete map neuron.
12#[derive(Clone, Debug)]
13pub struct ChialvoMapNeuron {
14    pub x: f64,
15    pub y: f64,
16    pub a: f64,
17    pub b: f64,
18    pub c: f64,
19    pub k: f64,
20    pub x_threshold: f64,
21}
22
23impl ChialvoMapNeuron {
24    pub fn new() -> Self {
25        Self {
26            x: 0.0,
27            y: 0.0,
28            a: 0.89,
29            b: 0.6,
30            c: 0.28,
31            k: 0.04,
32            x_threshold: 1.0,
33        }
34    }
35    pub fn step(&mut self, current: f64) -> i32 {
36        let x_prev = self.x;
37        let x_new = self.x * self.x * (self.y - self.x).exp() + self.k + current;
38        let y_new = self.a * self.y - self.b * self.x + self.c;
39        self.x = x_new;
40        self.y = y_new;
41        if self.x >= self.x_threshold && x_prev < self.x_threshold {
42            1
43        } else {
44            0
45        }
46    }
47    pub fn reset(&mut self) {
48        self.x = 0.0;
49        self.y = 0.0;
50    }
51}
52impl Default for ChialvoMapNeuron {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58/// Rulkov 2001 — piecewise nonlinear map for fast/slow bursting.
59#[derive(Clone, Debug)]
60pub struct RulkovMapNeuron {
61    pub x: f64,
62    pub y: f64,
63    pub alpha: f64,
64    pub sigma: f64,
65    pub mu: f64,
66    pub x_threshold: f64,
67}
68
69impl RulkovMapNeuron {
70    pub fn new() -> Self {
71        Self {
72            x: -1.0,
73            y: -3.0,
74            alpha: 4.0,
75            sigma: -1.6,
76            mu: 0.001,
77            x_threshold: 0.0,
78        }
79    }
80    pub fn step(&mut self, current: f64) -> i32 {
81        let x_prev = self.x;
82        let x_new = if self.x <= 0.0 {
83            self.alpha / (1.0 - self.x) + self.y + current
84        } else if self.x < self.alpha + self.y + current {
85            self.alpha + self.y + current
86        } else {
87            -1.0
88        };
89        let y_new = self.y - self.mu * (self.x + 1.0) + self.mu * self.sigma;
90        self.x = x_new;
91        self.y = y_new;
92        if self.x >= self.x_threshold && x_prev < self.x_threshold {
93            1
94        } else {
95            0
96        }
97    }
98    /// Run `n_steps` under a constant input, returning the `x` trace and the
99    /// upward-crossing spike count. Reuses `step` so the trace is bit-identical
100    /// to the per-step path and to the Python reference. The final state is
101    /// left in `self.x` / `self.y`.
102    pub fn simulate(&mut self, n_steps: usize, current: f64) -> (Vec<f64>, i64) {
103        let mut trace = Vec::with_capacity(n_steps);
104        let mut spikes: i64 = 0;
105        for _ in 0..n_steps {
106            let spiked = self.step(current);
107            trace.push(self.x);
108            spikes += spiked as i64;
109        }
110        (trace, spikes)
111    }
112    pub fn reset(&mut self) {
113        self.x = -1.0;
114        self.y = -3.0;
115    }
116}
117impl Default for RulkovMapNeuron {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123/// Ibarz-Tanaka map — piecewise-linear spiking map.
124#[derive(Clone, Debug)]
125pub struct IbarzTanakaMapNeuron {
126    pub x: f64,
127    pub y: f64,
128    pub alpha: f64,
129    pub beta: f64,
130    pub mu: f64,
131    pub sigma: f64,
132    pub x_threshold: f64,
133    pub x_reset: f64,
134}
135
136impl IbarzTanakaMapNeuron {
137    pub fn new() -> Self {
138        Self {
139            x: -1.0,
140            y: -2.5,
141            alpha: 3.65,
142            beta: 0.25,
143            mu: 0.0005,
144            sigma: -1.6,
145            x_threshold: 3.0,
146            x_reset: -1.0,
147        }
148    }
149    pub fn step(&mut self, current: f64) -> i32 {
150        let f = if self.x <= 0.0 {
151            self.alpha / (1.0 - self.x)
152        } else {
153            self.alpha + self.beta * self.x
154        };
155        let x_new = f + self.y + current;
156        let y_new = self.y - self.mu * (self.x + 1.0) + self.mu * self.sigma;
157        self.x = x_new;
158        self.y = y_new;
159        if self.x >= self.x_threshold {
160            self.x = self.x_reset;
161            1
162        } else {
163            0
164        }
165    }
166    /// Run `n_steps` under a constant input, returning the `x` trace (already
167    /// reset to `x_reset` on spiking steps) and the spike count. Reuses `step`
168    /// so the trace is bit-identical to the per-step path and to the Python
169    /// reference. The final state is left in `self.x` / `self.y`.
170    pub fn simulate(&mut self, n_steps: usize, current: f64) -> (Vec<f64>, i64) {
171        let mut trace = Vec::with_capacity(n_steps);
172        let mut spikes: i64 = 0;
173        for _ in 0..n_steps {
174            let spiked = self.step(current);
175            trace.push(self.x);
176            spikes += spiked as i64;
177        }
178        (trace, spikes)
179    }
180    pub fn reset(&mut self) {
181        self.x = -1.0;
182        self.y = -2.5;
183    }
184}
185impl Default for IbarzTanakaMapNeuron {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191/// Medvedev map — piecewise monotone 1D neuron map.
192#[derive(Clone, Debug)]
193pub struct MedvedevMapNeuron {
194    pub x: f64,
195    pub alpha: f64,
196    pub beta: f64,
197    pub x_threshold: f64,
198}
199
200impl MedvedevMapNeuron {
201    pub fn new() -> Self {
202        Self {
203            x: 0.0,
204            alpha: 3.5,
205            beta: 0.5,
206            x_threshold: 0.9,
207        }
208    }
209    pub fn step(&mut self, current: f64) -> i32 {
210        let x_prev = self.x;
211        self.x = if self.x < self.beta {
212            self.alpha * self.x + current
213        } else {
214            self.alpha * (1.0 - self.x) + current
215        };
216        self.x = self.x.rem_euclid(1.0);
217        if self.x >= self.x_threshold && x_prev < self.x_threshold {
218            1
219        } else {
220            0
221        }
222    }
223    /// Run `n_steps` under a constant input, returning the `x` trace (folded
224    /// into `[0, 1)`) and the upward-crossing spike count. Reuses `step` so the
225    /// trace is bit-identical to the per-step path and to the Python reference
226    /// (`f64::rem_euclid(1.0)` equals Python's `x % 1.0` bit-for-bit). The final
227    /// state is left in `self.x`.
228    pub fn simulate(&mut self, n_steps: usize, current: f64) -> (Vec<f64>, i64) {
229        let mut trace = Vec::with_capacity(n_steps);
230        let mut spikes: i64 = 0;
231        for _ in 0..n_steps {
232            let spiked = self.step(current);
233            trace.push(self.x);
234            spikes += spiked as i64;
235        }
236        (trace, spikes)
237    }
238    pub fn reset(&mut self) {
239        self.x = 0.0;
240    }
241}
242impl Default for MedvedevMapNeuron {
243    fn default() -> Self {
244        Self::new()
245    }
246}
247
248/// Cazelles logistic map neuron — coupled 2D logistic with slow variable.
249#[derive(Clone, Debug)]
250pub struct CazellesMapNeuron {
251    pub x: f64,
252    pub y: f64,
253    pub a: f64,
254    pub epsilon: f64,
255    pub sigma: f64,
256    pub x_threshold: f64,
257}
258
259impl CazellesMapNeuron {
260    pub fn new() -> Self {
261        Self {
262            x: 0.1,
263            y: 0.0,
264            a: 3.8,
265            epsilon: 0.01,
266            sigma: 0.5,
267            x_threshold: 0.9,
268        }
269    }
270    pub fn step(&mut self, current: f64) -> i32 {
271        let f = self.a * self.x * (1.0 - self.x);
272        let x_new = (f - self.y + current).clamp(-2.0, 2.0);
273        let y_new = self.y + self.epsilon * (self.x - self.sigma);
274        self.x = x_new;
275        self.y = y_new;
276        if self.x >= self.x_threshold {
277            1
278        } else {
279            0
280        }
281    }
282    /// Run `n_steps` under a constant input, returning the `x` trace and the
283    /// spike count. Reuses `step` so the trace is bit-identical to the
284    /// per-step path and to the Python reference. The final state is left in
285    /// `self.x` / `self.y`.
286    pub fn simulate(&mut self, n_steps: usize, current: f64) -> (Vec<f64>, i64) {
287        let mut trace = Vec::with_capacity(n_steps);
288        let mut spikes: i64 = 0;
289        for _ in 0..n_steps {
290            let spiked = self.step(current);
291            trace.push(self.x);
292            spikes += spiked as i64;
293        }
294        (trace, spikes)
295    }
296    pub fn reset(&mut self) {
297        self.x = 0.1;
298        self.y = 0.0;
299    }
300}
301impl Default for CazellesMapNeuron {
302    fn default() -> Self {
303        Self::new()
304    }
305}
306
307/// Courbage-Nekorkin-Vdovin (2007) discontinuous two-dimensional spiking map.
308///
309/// Canonical map (Chaos 17:043109; arXiv:0712.2097, eqs. 3-5):
310///
311/// ```text
312/// x(n+1) = x(n) + F(x(n)) - y(n) - beta*H(x(n) - d) + I
313/// y(n+1) = y(n) + eps*(x(n) - J)
314/// F(x)   = -m0*x        for x <= Jmin
315///          m1*(x - a)   for Jmin < x < Jmax
316///          -m0*(x - 1)  for x >= Jmax
317/// H(z)   = 1 for z >= 0, else 0
318/// Jmin   = a*m1/(m0 + m1),  Jmax = (m0 + a*m1)/(m0 + m1)
319/// ```
320///
321/// Defaults place the model in the published chaotic spiking-bursting regime.
322/// The arithmetic is exact (no transcendental functions), so `simulate` is
323/// bit-identical to the Python NumPy reference and the Julia/Go backends.
324#[derive(Clone, Debug)]
325pub struct CourageNekorkinMapNeuron {
326    pub x: f64,
327    pub y: f64,
328    pub m0: f64,
329    pub m1: f64,
330    pub a: f64,
331    pub d: f64,
332    pub j: f64,
333    pub beta: f64,
334    pub eps: f64,
335    pub x_threshold: f64,
336}
337
338impl CourageNekorkinMapNeuron {
339    pub fn new() -> Self {
340        Self {
341            x: 0.0,
342            y: 0.0,
343            m0: 0.0864,
344            m1: 0.65,
345            a: 0.2,
346            d: 0.235,
347            j: 0.2,
348            beta: 0.085,
349            eps: 0.02,
350            x_threshold: 0.235,
351        }
352    }
353    pub fn step(&mut self, current: f64) -> i32 {
354        let x_prev = self.x;
355        let am1 = self.a * self.m1;
356        let den = self.m0 + self.m1;
357        let jmin = am1 / den;
358        let jmax = (self.m0 + am1) / den;
359        let fx = if self.x <= jmin {
360            -self.m0 * self.x
361        } else if self.x < jmax {
362            self.m1 * (self.x - self.a)
363        } else {
364            -self.m0 * (self.x - 1.0)
365        };
366        let h = if (self.x - self.d) >= 0.0 { 1.0 } else { 0.0 };
367        let x_new = self.x + fx - self.y - self.beta * h + current;
368        let y_new = self.y + self.eps * (self.x - self.j);
369        self.x = x_new;
370        self.y = y_new;
371        if self.x >= self.x_threshold && x_prev < self.x_threshold {
372            1
373        } else {
374            0
375        }
376    }
377    /// Run `n_steps` under a constant input, returning the `x` trace and the
378    /// upward-crossing spike count. Reuses `step` so the trace is bit-identical
379    /// to the per-step path and to the Python reference. The final state is left
380    /// in `self.x` / `self.y`.
381    pub fn simulate(&mut self, n_steps: usize, current: f64) -> (Vec<f64>, i64) {
382        let mut trace = Vec::with_capacity(n_steps);
383        let mut spikes: i64 = 0;
384        for _ in 0..n_steps {
385            let spiked = self.step(current);
386            trace.push(self.x);
387            spikes += spiked as i64;
388        }
389        (trace, spikes)
390    }
391    pub fn reset(&mut self) {
392        self.x = 0.0;
393        self.y = 0.0;
394    }
395}
396impl Default for CourageNekorkinMapNeuron {
397    fn default() -> Self {
398        Self::new()
399    }
400}
401
402/// Aihara 1990 — chaotic neuron map with sigmoid nonlinearity.
403///
404/// 2D discrete map producing chaotic spiking, bursting, and tonic firing
405/// depending on parameters. The sigmoid output function models the
406/// nonlinear voltage-to-firing-rate relationship.
407///
408/// x(n+1) = k_f * x(n) / (1 + exp(-(x(n) + alpha))) - y(n) + I
409/// y(n+1) = k_s * y(n) + delta * x(n)
410///
411/// Aihara et al., Phys Lett A 144:333, 1990.
412#[derive(Clone, Debug)]
413pub struct AiharaMapNeuron {
414    pub x: f64,
415    pub y: f64,
416    pub k_f: f64,   // Fast variable decay
417    pub k_s: f64,   // Slow variable decay
418    pub alpha: f64, // Sigmoid steepness offset
419    pub delta: f64, // Slow→fast coupling
420    pub x_threshold: f64,
421}
422
423impl Default for AiharaMapNeuron {
424    fn default() -> Self {
425        Self::new()
426    }
427}
428
429impl AiharaMapNeuron {
430    pub fn new() -> Self {
431        Self {
432            x: 0.0,
433            y: 0.0,
434            k_f: 0.7,
435            k_s: 0.95,
436            alpha: 2.0,
437            delta: 0.05,
438            x_threshold: 0.5,
439        }
440    }
441
442    pub fn step(&mut self, current: f64) -> i32 {
443        let x_prev = self.x;
444        let sigmoid = 1.0 / (1.0 + (-(self.x + self.alpha)).exp());
445        let x_new = self.k_f * self.x * sigmoid - self.y + current;
446        let y_new = self.k_s * self.y + self.delta * self.x;
447
448        self.x = x_new.clamp(-10.0, 10.0);
449        self.y = y_new.clamp(-10.0, 10.0);
450
451        if !self.x.is_finite() {
452            self.x = 0.0;
453        }
454        if !self.y.is_finite() {
455            self.y = 0.0;
456        }
457
458        if self.x >= self.x_threshold && x_prev < self.x_threshold {
459            1
460        } else {
461            0
462        }
463    }
464
465    pub fn reset(&mut self) {
466        *self = Self::new();
467    }
468}
469
470/// Kilinc-Bhatt 2023 — sigmoid map with adaptive threshold.
471///
472/// Minimal 2D map with built-in spike frequency adaptation via
473/// a slow threshold variable. Designed for efficient hardware
474/// implementation while retaining biologically relevant dynamics.
475///
476/// x(n+1) = k * sigmoid(x(n) - theta(n)) + I
477/// theta(n+1) = beta * theta(n) + gamma * H(x(n) - theta_spike)
478///
479/// H() is the Heaviside step function (spike-triggered increment).
480#[derive(Clone, Debug)]
481pub struct KilincBhattMapNeuron {
482    pub x: f64,
483    pub theta: f64,       // Adaptive threshold
484    pub k: f64,           // Gain
485    pub beta: f64,        // Threshold decay
486    pub gamma: f64,       // Spike→threshold coupling
487    pub theta_spike: f64, // Spike detection level
488    pub x_threshold: f64,
489}
490
491impl Default for KilincBhattMapNeuron {
492    fn default() -> Self {
493        Self::new()
494    }
495}
496
497impl KilincBhattMapNeuron {
498    pub fn new() -> Self {
499        Self {
500            x: 0.0,
501            theta: 0.0,
502            k: 1.5,
503            beta: 0.95,
504            gamma: 0.3,
505            theta_spike: 0.8,
506            x_threshold: 0.8,
507        }
508    }
509
510    pub fn step(&mut self, current: f64) -> i32 {
511        let x_prev = self.x;
512        let sig = 1.0 / (1.0 + (-(self.x - self.theta) * 4.0).exp());
513        let x_new = -self.x + self.k * sig + current;
514        let spiked = if self.x >= self.theta_spike { 1.0 } else { 0.0 };
515        let theta_new = self.beta * self.theta + self.gamma * spiked;
516
517        self.x = x_new.clamp(-5.0, 5.0);
518        self.theta = theta_new.clamp(-5.0, 5.0);
519
520        if !self.x.is_finite() {
521            self.x = 0.0;
522        }
523        if !self.theta.is_finite() {
524            self.theta = 0.0;
525        }
526
527        if self.x >= self.x_threshold && x_prev < self.x_threshold {
528            1
529        } else {
530            0
531        }
532    }
533
534    pub fn reset(&mut self) {
535        *self = Self::new();
536    }
537}
538
539/// Ermentrout-Kopell canonical Type I — theta neuron in map form.
540///
541/// The canonical model for Type I (saddle-node) excitability.
542/// theta(n+1) = theta(n) + dt * (1 - cos(theta)) + (1 + cos(theta)) * I
543/// Spike when theta crosses pi.
544///
545/// Ermentrout & Kopell, SIAM J Appl Math 46:233, 1986.
546#[derive(Clone, Debug)]
547pub struct ErmentroutKopellMapNeuron {
548    pub theta: f64, // Phase variable [0, 2*pi)
549    pub dt: f64,
550    pub gain: f64,
551    pub theta_threshold: f64,
552}
553
554impl Default for ErmentroutKopellMapNeuron {
555    fn default() -> Self {
556        Self::new()
557    }
558}
559
560impl ErmentroutKopellMapNeuron {
561    pub fn new() -> Self {
562        Self {
563            theta: 0.0,
564            dt: 0.1, // Discrete step size
565            gain: 1.0,
566            theta_threshold: std::f64::consts::PI,
567        }
568    }
569
570    pub fn step(&mut self, current: f64) -> i32 {
571        let input = self.gain * current;
572        let theta_prev = self.theta;
573
574        let d_theta = (1.0 - self.theta.cos()) + (1.0 + self.theta.cos()) * input;
575        self.theta += self.dt * d_theta;
576
577        // Spike detection: crossing pi
578        let fired = if self.theta >= self.theta_threshold && theta_prev < self.theta_threshold {
579            1
580        } else {
581            0
582        };
583
584        // Wrap theta to [0, 2*pi)
585        let two_pi = 2.0 * std::f64::consts::PI;
586        if self.theta >= two_pi {
587            self.theta -= two_pi;
588        }
589        if self.theta < 0.0 {
590            self.theta += two_pi;
591        }
592
593        if !self.theta.is_finite() {
594            self.theta = 0.0;
595        }
596
597        fired
598    }
599
600    /// Run `n_steps` under a constant input, returning the `theta` trace
601    /// (wrapped to `[0, 2*pi)`) and the upward-crossing spike count. Reuses
602    /// `step` so the trace matches the per-step path; on a shared libm it also
603    /// matches the Python reference bit-for-bit (the only transcendental is
604    /// `cos`, and the non-chaotic phase flow does not amplify ULP differences).
605    /// The final state is left in `self.theta`.
606    pub fn simulate(&mut self, n_steps: usize, current: f64) -> (Vec<f64>, i64) {
607        let mut trace = Vec::with_capacity(n_steps);
608        let mut spikes: i64 = 0;
609        for _ in 0..n_steps {
610            let spiked = self.step(current);
611            trace.push(self.theta);
612            spikes += spiked as i64;
613        }
614        (trace, spikes)
615    }
616
617    pub fn reset(&mut self) {
618        *self = Self::new();
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn chialvo_fires() {
628        let mut n = ChialvoMapNeuron::new();
629        let t: i32 = (0..1000).map(|_| n.step(1.0)).sum();
630        assert!(t > 0);
631    }
632    #[test]
633    fn rulkov_fires() {
634        let mut n = RulkovMapNeuron::new();
635        let t: i32 = (0..2000).map(|_| n.step(0.5)).sum();
636        assert!(t > 0);
637    }
638    #[test]
639    fn ibarz_fires() {
640        let mut n = IbarzTanakaMapNeuron::new();
641        let t: i32 = (0..2000).map(|_| n.step(2.0)).sum();
642        assert!(t > 0);
643    }
644    #[test]
645    fn medvedev_fires() {
646        let mut n = MedvedevMapNeuron {
647            x: 0.5,
648            ..Default::default()
649        };
650        let t: i32 = (0..500).map(|_| n.step(0.1)).sum();
651        assert!(t > 0);
652    }
653    #[test]
654    fn cazelles_fires() {
655        let mut n = CazellesMapNeuron::new();
656        let t: i32 = (0..200).map(|_| n.step(0.0)).sum();
657        assert!(t > 0);
658    }
659    #[test]
660    fn cournekorkin_fires() {
661        let mut n = CourageNekorkinMapNeuron::new();
662        let t: i32 = (0..200).map(|_| n.step(0.5)).sum();
663        assert!(t > 0);
664    }
665
666    // -- Aihara Map coverage tests --
667
668    #[test]
669    fn aihara_fires_with_input() {
670        let mut n = AiharaMapNeuron::new();
671        let t: i32 = (0..2000).map(|_| n.step(1.0)).sum();
672        assert!(t > 0, "Aihara must fire with input, got {t}");
673    }
674
675    #[test]
676    fn aihara_silent_without_input() {
677        let mut n = AiharaMapNeuron::new();
678        let t: i32 = (0..5000).map(|_| n.step(0.0)).sum();
679        assert_eq!(t, 0, "Aihara must be silent without input, got {t}");
680    }
681
682    #[test]
683    fn aihara_chaotic_dynamics() {
684        // With appropriate input, trajectory should not settle to fixed point
685        let mut n = AiharaMapNeuron::new();
686        let mut values = Vec::new();
687        for _ in 0..1000 {
688            n.step(0.5);
689            values.push(n.x);
690        }
691        let mean = values.iter().sum::<f64>() / values.len() as f64;
692        let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
693        assert!(
694            var > 0.001,
695            "Trajectory should show variability (chaos), var={var}"
696        );
697    }
698
699    #[test]
700    fn aihara_negative_input_no_crash() {
701        let mut n = AiharaMapNeuron::new();
702        for _ in 0..10_000 {
703            n.step(-100.0);
704        }
705        assert!(n.x.is_finite());
706    }
707
708    #[test]
709    fn aihara_nan_input_stays_finite() {
710        let mut n = AiharaMapNeuron::new();
711        n.step(f64::NAN);
712        assert!(n.x.is_finite());
713    }
714
715    #[test]
716    fn aihara_extreme_input_bounded() {
717        let mut n = AiharaMapNeuron::new();
718        for _ in 0..1000 {
719            n.step(1e6);
720        }
721        assert!(n.x.is_finite() && n.x <= 1e6);
722    }
723
724    #[test]
725    fn aihara_reset_clears_state() {
726        let mut n = AiharaMapNeuron::new();
727        for _ in 0..100 {
728            n.step(1.0);
729        }
730        n.reset();
731        assert_eq!(n.x, 0.0);
732        assert_eq!(n.y, 0.0);
733    }
734
735    #[test]
736    fn aihara_rate_increases_with_input() {
737        let mut low = AiharaMapNeuron::new();
738        let mut high = AiharaMapNeuron::new();
739        let spikes_low: i32 = (0..5000).map(|_| low.step(0.5)).sum();
740        let spikes_high: i32 = (0..5000).map(|_| high.step(2.0)).sum();
741        assert!(
742            spikes_high >= spikes_low,
743            "Higher input should produce more spikes: high={spikes_high} vs low={spikes_low}"
744        );
745    }
746
747    #[test]
748    fn aihara_performance_100k_steps() {
749        let start = std::time::Instant::now();
750        let mut n = AiharaMapNeuron::new();
751        for _ in 0..100_000 {
752            std::hint::black_box(n.step(0.5));
753        }
754        let elapsed = start.elapsed();
755        assert!(
756            elapsed.as_millis() < 50,
757            "100k steps must complete in <50ms"
758        );
759    }
760
761    // -- Kilinc-Bhatt Map coverage tests --
762
763    #[test]
764    fn kb_fires_with_input() {
765        let mut n = KilincBhattMapNeuron::new();
766        let t: i32 = (0..5000).map(|_| n.step(1.0)).sum();
767        assert!(t > 0, "KB must fire with input, got {t}");
768    }
769
770    #[test]
771    fn kb_silent_without_input() {
772        let mut n = KilincBhattMapNeuron::new();
773        let t: i32 = (0..5000).map(|_| n.step(0.0)).sum();
774        assert_eq!(t, 0, "KB must be silent without input, got {t}");
775    }
776
777    #[test]
778    fn kb_adaptation() {
779        // Theta increases with spiking → fewer spikes over time
780        let mut n = KilincBhattMapNeuron::new();
781        let early: i32 = (0..2000).map(|_| n.step(1.0)).sum();
782        let late: i32 = (0..2000).map(|_| n.step(1.0)).sum();
783        assert!(
784            early >= late,
785            "Adaptation should slow firing: early={early}, late={late}"
786        );
787    }
788
789    #[test]
790    fn kb_theta_increases_during_spiking() {
791        let mut n = KilincBhattMapNeuron::new();
792        let theta_before = n.theta;
793        for _ in 0..5000 {
794            n.step(1.5);
795        }
796        assert!(
797            n.theta > theta_before,
798            "Theta must increase during spiking, theta={}",
799            n.theta
800        );
801    }
802
803    #[test]
804    fn kb_negative_input_no_crash() {
805        let mut n = KilincBhattMapNeuron::new();
806        for _ in 0..10_000 {
807            n.step(-100.0);
808        }
809        assert!(n.x.is_finite());
810    }
811
812    #[test]
813    fn kb_nan_input_stays_finite() {
814        let mut n = KilincBhattMapNeuron::new();
815        n.step(f64::NAN);
816        assert!(n.x.is_finite());
817    }
818
819    #[test]
820    fn kb_extreme_input_bounded() {
821        let mut n = KilincBhattMapNeuron::new();
822        for _ in 0..1000 {
823            n.step(1e6);
824        }
825        assert!(n.x.is_finite() && n.x <= 5.0);
826    }
827
828    #[test]
829    fn kb_reset_clears_state() {
830        let mut n = KilincBhattMapNeuron::new();
831        for _ in 0..100 {
832            n.step(1.0);
833        }
834        n.reset();
835        assert_eq!(n.x, 0.0);
836        assert_eq!(n.theta, 0.0);
837    }
838
839    #[test]
840    fn kb_performance_100k_steps() {
841        let start = std::time::Instant::now();
842        let mut n = KilincBhattMapNeuron::new();
843        for _ in 0..100_000 {
844            std::hint::black_box(n.step(0.5));
845        }
846        let elapsed = start.elapsed();
847        assert!(
848            elapsed.as_millis() < 50,
849            "100k steps must complete in <50ms"
850        );
851    }
852
853    // -- Ermentrout-Kopell Map coverage tests --
854
855    #[test]
856    fn ek_fires_with_input() {
857        let mut n = ErmentroutKopellMapNeuron::new();
858        let t: i32 = (0..5000).map(|_| n.step(0.5)).sum();
859        assert!(t > 0, "EK must fire with input, got {t}");
860    }
861
862    #[test]
863    fn ek_silent_without_input() {
864        // Type I: no firing below threshold (I < 0 is subthreshold for theta model)
865        let mut n = ErmentroutKopellMapNeuron::new();
866        let t: i32 = (0..5000).map(|_| n.step(-0.1)).sum();
867        assert_eq!(t, 0, "EK must be silent with negative input, got {t}");
868    }
869
870    #[test]
871    fn ek_type_i_excitability() {
872        // Type I: arbitrarily low firing rate near threshold
873        let mut n_low = ErmentroutKopellMapNeuron::new();
874        let mut n_high = ErmentroutKopellMapNeuron::new();
875        let spikes_low: i32 = (0..10_000).map(|_| n_low.step(0.01)).sum();
876        let spikes_high: i32 = (0..10_000).map(|_| n_high.step(1.0)).sum();
877        assert!(
878            spikes_high > spikes_low,
879            "Higher input → higher rate: high={spikes_high} vs low={spikes_low}"
880        );
881    }
882
883    #[test]
884    fn ek_theta_wraps() {
885        // Theta should stay in [0, 2*pi)
886        let mut n = ErmentroutKopellMapNeuron::new();
887        for _ in 0..10_000 {
888            n.step(0.5);
889        }
890        let two_pi = 2.0 * std::f64::consts::PI;
891        assert!(
892            n.theta >= 0.0 && n.theta < two_pi,
893            "Theta must wrap to [0, 2pi), theta={}",
894            n.theta
895        );
896    }
897
898    #[test]
899    fn ek_negative_input_no_crash() {
900        let mut n = ErmentroutKopellMapNeuron::new();
901        for _ in 0..10_000 {
902            n.step(-100.0);
903        }
904        assert!(n.theta.is_finite());
905    }
906
907    #[test]
908    fn ek_nan_input_stays_finite() {
909        let mut n = ErmentroutKopellMapNeuron::new();
910        n.step(f64::NAN);
911        assert!(n.theta.is_finite());
912    }
913
914    #[test]
915    fn ek_extreme_input_bounded() {
916        let mut n = ErmentroutKopellMapNeuron::new();
917        for _ in 0..1000 {
918            n.step(1e6);
919        }
920        assert!(n.theta.is_finite());
921    }
922
923    #[test]
924    fn ek_reset_clears_state() {
925        let mut n = ErmentroutKopellMapNeuron::new();
926        for _ in 0..100 {
927            n.step(0.5);
928        }
929        n.reset();
930        assert_eq!(n.theta, 0.0);
931    }
932
933    #[test]
934    fn ek_performance_100k_steps() {
935        let start = std::time::Instant::now();
936        let mut n = ErmentroutKopellMapNeuron::new();
937        for _ in 0..100_000 {
938            std::hint::black_box(n.step(0.5));
939        }
940        let elapsed = start.elapsed();
941        assert!(
942            elapsed.as_millis() < 50,
943            "100k steps must complete in <50ms"
944        );
945    }
946
947    // -- Courbage-Nekorkin-Vdovin 2007 canonical map tests --
948
949    #[test]
950    fn cn_default_sustained_bounded_spiking() {
951        // The default parameters are the published chaotic spiking-bursting
952        // regime: sustained firing with a bounded (clip-free) trajectory.
953        let mut n = CourageNekorkinMapNeuron::new();
954        let mut spikes = 0i64;
955        let mut max_abs = 0.0f64;
956        for _ in 0..20_000 {
957            spikes += n.step(0.0) as i64;
958            max_abs = max_abs.max(n.x.abs());
959        }
960        assert!(spikes > 1000, "expected sustained spiking, got {spikes}");
961        assert!(
962            max_abs < 10.0,
963            "trajectory must stay bounded, got {max_abs}"
964        );
965    }
966
967    #[test]
968    fn cn_breakpoints_inside_region() {
969        // Default discontinuity d sits strictly inside (Jmin, Jmax) — eq. 6.
970        let n = CourageNekorkinMapNeuron::new();
971        let am1 = n.a * n.m1;
972        let den = n.m0 + n.m1;
973        let jmin = am1 / den;
974        let jmax = (n.m0 + am1) / den;
975        assert!(jmin < n.d && n.d < jmax);
976        assert!(n.j > 0.0 && n.j < n.d);
977        assert!(n.m0 < 1.0);
978    }
979
980    #[test]
981    fn cn_f_piecewise_branches() {
982        // F lower/middle/upper branches (eq. 4).
983        let n = CourageNekorkinMapNeuron::new();
984        let am1 = n.a * n.m1;
985        let den = n.m0 + n.m1;
986        let jmin = am1 / den;
987        let jmax = (n.m0 + am1) / den;
988        // Replicate the in-step branch selection on a probe value per region.
989        let f = |x: f64| {
990            if x <= jmin {
991                -n.m0 * x
992            } else if x < jmax {
993                n.m1 * (x - n.a)
994            } else {
995                -n.m0 * (x - 1.0)
996            }
997        };
998        assert_eq!(f(jmin - 0.05), -n.m0 * (jmin - 0.05));
999        let mid = 0.5 * (jmin + jmax);
1000        assert_eq!(f(mid), n.m1 * (mid - n.a));
1001        assert_eq!(f(jmax + 0.05), -n.m0 * (jmax + 0.05 - 1.0));
1002    }
1003
1004    #[test]
1005    fn cn_heaviside_subtracts_beta_above_d() {
1006        // At x >= d the Heaviside term removes exactly beta from the x update.
1007        let mut with_beta = CourageNekorkinMapNeuron::new();
1008        with_beta.x = 0.30; // >= d
1009        let mut no_beta = with_beta.clone();
1010        no_beta.beta = 0.0;
1011        with_beta.step(0.0);
1012        no_beta.step(0.0);
1013        assert!((with_beta.x - no_beta.x - (-0.085)).abs() < 1e-15);
1014    }
1015
1016    #[test]
1017    fn cn_simulate_matches_repeated_step() {
1018        let (trace, spikes) = CourageNekorkinMapNeuron::new().simulate(500, 0.0);
1019        let mut stepper = CourageNekorkinMapNeuron::new();
1020        let mut manual = Vec::with_capacity(500);
1021        let mut sp = 0i64;
1022        for _ in 0..500 {
1023            sp += stepper.step(0.0) as i64;
1024            manual.push(stepper.x);
1025        }
1026        assert_eq!(trace, manual);
1027        assert_eq!(spikes, sp);
1028        assert_eq!(
1029            stepper.x,
1030            CourageNekorkinMapNeuron::new().simulate(500, 0.0).0[499]
1031        );
1032    }
1033
1034    #[test]
1035    fn cn_reset_clears_state() {
1036        let mut n = CourageNekorkinMapNeuron::new();
1037        for _ in 0..100 {
1038            n.step(0.0);
1039        }
1040        n.reset();
1041        assert_eq!(n.x, 0.0);
1042        assert_eq!(n.y, 0.0);
1043    }
1044
1045    #[test]
1046    fn cn_deterministic() {
1047        let (a, sa) = CourageNekorkinMapNeuron::new().simulate(1000, 0.05);
1048        let (b, sb) = CourageNekorkinMapNeuron::new().simulate(1000, 0.05);
1049        assert_eq!(a, b);
1050        assert_eq!(sa, sb);
1051    }
1052
1053    #[test]
1054    fn cn_performance_100k_steps() {
1055        let start = std::time::Instant::now();
1056        let mut n = CourageNekorkinMapNeuron::new();
1057        for _ in 0..100_000 {
1058            std::hint::black_box(n.step(0.0));
1059        }
1060        let elapsed = start.elapsed();
1061        assert!(
1062            elapsed.as_millis() < 50,
1063            "100k steps must complete in <50ms"
1064        );
1065    }
1066}