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    pub fn reset(&mut self) {
99        self.x = -1.0;
100        self.y = -3.0;
101    }
102}
103impl Default for RulkovMapNeuron {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109/// Ibarz-Tanaka map — piecewise-linear spiking map.
110#[derive(Clone, Debug)]
111pub struct IbarzTanakaMapNeuron {
112    pub x: f64,
113    pub y: f64,
114    pub alpha: f64,
115    pub beta: f64,
116    pub mu: f64,
117    pub sigma: f64,
118    pub x_threshold: f64,
119    pub x_reset: f64,
120}
121
122impl IbarzTanakaMapNeuron {
123    pub fn new() -> Self {
124        Self {
125            x: -1.0,
126            y: -2.5,
127            alpha: 3.65,
128            beta: 0.25,
129            mu: 0.0005,
130            sigma: -1.6,
131            x_threshold: 3.0,
132            x_reset: -1.0,
133        }
134    }
135    pub fn step(&mut self, current: f64) -> i32 {
136        let f = if self.x <= 0.0 {
137            self.alpha / (1.0 - self.x)
138        } else {
139            self.alpha + self.beta * self.x
140        };
141        let x_new = f + self.y + current;
142        let y_new = self.y - self.mu * (self.x + 1.0) + self.mu * self.sigma;
143        self.x = x_new;
144        self.y = y_new;
145        if self.x >= self.x_threshold {
146            self.x = self.x_reset;
147            1
148        } else {
149            0
150        }
151    }
152    pub fn reset(&mut self) {
153        self.x = -1.0;
154        self.y = -2.5;
155    }
156}
157impl Default for IbarzTanakaMapNeuron {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163/// Medvedev map — piecewise monotone 1D neuron map.
164#[derive(Clone, Debug)]
165pub struct MedvedevMapNeuron {
166    pub x: f64,
167    pub alpha: f64,
168    pub beta: f64,
169    pub x_threshold: f64,
170}
171
172impl MedvedevMapNeuron {
173    pub fn new() -> Self {
174        Self {
175            x: 0.0,
176            alpha: 3.5,
177            beta: 0.5,
178            x_threshold: 0.9,
179        }
180    }
181    pub fn step(&mut self, current: f64) -> i32 {
182        let x_prev = self.x;
183        self.x = if self.x < self.beta {
184            self.alpha * self.x + current
185        } else {
186            self.alpha * (1.0 - self.x) + current
187        };
188        self.x = self.x.rem_euclid(1.0);
189        if self.x >= self.x_threshold && x_prev < self.x_threshold {
190            1
191        } else {
192            0
193        }
194    }
195    pub fn reset(&mut self) {
196        self.x = 0.0;
197    }
198}
199impl Default for MedvedevMapNeuron {
200    fn default() -> Self {
201        Self::new()
202    }
203}
204
205/// Cazelles logistic map neuron — coupled 2D logistic with slow variable.
206#[derive(Clone, Debug)]
207pub struct CazellesMapNeuron {
208    pub x: f64,
209    pub y: f64,
210    pub a: f64,
211    pub epsilon: f64,
212    pub sigma: f64,
213    pub x_threshold: f64,
214}
215
216impl CazellesMapNeuron {
217    pub fn new() -> Self {
218        Self {
219            x: 0.1,
220            y: 0.0,
221            a: 3.8,
222            epsilon: 0.01,
223            sigma: 0.5,
224            x_threshold: 0.9,
225        }
226    }
227    pub fn step(&mut self, current: f64) -> i32 {
228        let f = self.a * self.x * (1.0 - self.x);
229        let x_new = (f - self.y + current).clamp(-2.0, 2.0);
230        let y_new = self.y + self.epsilon * (self.x - self.sigma);
231        self.x = x_new;
232        self.y = y_new;
233        if self.x >= self.x_threshold {
234            1
235        } else {
236            0
237        }
238    }
239    pub fn reset(&mut self) {
240        self.x = 0.1;
241        self.y = 0.0;
242    }
243}
244impl Default for CazellesMapNeuron {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250/// Courbage-Nekorkin piecewise-linear Lorenz-type map.
251#[derive(Clone, Debug)]
252pub struct CourageNekorkinMapNeuron {
253    pub x: f64,
254    pub y: f64,
255    pub alpha: f64,
256    pub beta: f64,
257    pub j: f64,
258    pub x_threshold: f64,
259}
260
261impl CourageNekorkinMapNeuron {
262    pub fn new() -> Self {
263        Self {
264            x: 0.0,
265            y: 0.0,
266            alpha: 3.0,
267            beta: 0.001,
268            j: 0.1,
269            x_threshold: 1.0,
270        }
271    }
272    pub fn step(&mut self, current: f64) -> i32 {
273        let x_prev = self.x;
274        let f = if self.x < 0.0 {
275            self.alpha * self.x
276        } else {
277            self.alpha * self.x / (1.0 + self.alpha * self.x)
278        };
279        let x_new = (f + self.y + current + self.j).clamp(-1e6, 1e6);
280        let y_new = (self.y - self.beta * (self.x + 1.0)).clamp(-1e6, 1e6);
281        self.x = if x_new.is_finite() { x_new } else { 0.0 };
282        self.y = if y_new.is_finite() { y_new } else { 0.0 };
283        if self.x >= self.x_threshold && x_prev < self.x_threshold {
284            1
285        } else {
286            0
287        }
288    }
289    pub fn reset(&mut self) {
290        self.x = 0.0;
291        self.y = 0.0;
292    }
293}
294impl Default for CourageNekorkinMapNeuron {
295    fn default() -> Self {
296        Self::new()
297    }
298}
299
300/// Aihara 1990 — chaotic neuron map with sigmoid nonlinearity.
301///
302/// 2D discrete map producing chaotic spiking, bursting, and tonic firing
303/// depending on parameters. The sigmoid output function models the
304/// nonlinear voltage-to-firing-rate relationship.
305///
306/// x(n+1) = k_f * x(n) / (1 + exp(-(x(n) + alpha))) - y(n) + I
307/// y(n+1) = k_s * y(n) + delta * x(n)
308///
309/// Aihara et al., Phys Lett A 144:333, 1990.
310#[derive(Clone, Debug)]
311pub struct AiharaMapNeuron {
312    pub x: f64,
313    pub y: f64,
314    pub k_f: f64,   // Fast variable decay
315    pub k_s: f64,   // Slow variable decay
316    pub alpha: f64, // Sigmoid steepness offset
317    pub delta: f64, // Slow→fast coupling
318    pub x_threshold: f64,
319}
320
321impl Default for AiharaMapNeuron {
322    fn default() -> Self {
323        Self::new()
324    }
325}
326
327impl AiharaMapNeuron {
328    pub fn new() -> Self {
329        Self {
330            x: 0.0,
331            y: 0.0,
332            k_f: 0.7,
333            k_s: 0.95,
334            alpha: 2.0,
335            delta: 0.05,
336            x_threshold: 0.5,
337        }
338    }
339
340    pub fn step(&mut self, current: f64) -> i32 {
341        let x_prev = self.x;
342        let sigmoid = 1.0 / (1.0 + (-(self.x + self.alpha)).exp());
343        let x_new = self.k_f * self.x * sigmoid - self.y + current;
344        let y_new = self.k_s * self.y + self.delta * self.x;
345
346        self.x = x_new.clamp(-10.0, 10.0);
347        self.y = y_new.clamp(-10.0, 10.0);
348
349        if !self.x.is_finite() {
350            self.x = 0.0;
351        }
352        if !self.y.is_finite() {
353            self.y = 0.0;
354        }
355
356        if self.x >= self.x_threshold && x_prev < self.x_threshold {
357            1
358        } else {
359            0
360        }
361    }
362
363    pub fn reset(&mut self) {
364        *self = Self::new();
365    }
366}
367
368/// Kilinc-Bhatt 2023 — sigmoid map with adaptive threshold.
369///
370/// Minimal 2D map with built-in spike frequency adaptation via
371/// a slow threshold variable. Designed for efficient hardware
372/// implementation while retaining biologically relevant dynamics.
373///
374/// x(n+1) = k * sigmoid(x(n) - theta(n)) + I
375/// theta(n+1) = beta * theta(n) + gamma * H(x(n) - theta_spike)
376///
377/// H() is the Heaviside step function (spike-triggered increment).
378#[derive(Clone, Debug)]
379pub struct KilincBhattMapNeuron {
380    pub x: f64,
381    pub theta: f64,       // Adaptive threshold
382    pub k: f64,           // Gain
383    pub beta: f64,        // Threshold decay
384    pub gamma: f64,       // Spike→threshold coupling
385    pub theta_spike: f64, // Spike detection level
386    pub x_threshold: f64,
387}
388
389impl Default for KilincBhattMapNeuron {
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395impl KilincBhattMapNeuron {
396    pub fn new() -> Self {
397        Self {
398            x: 0.0,
399            theta: 0.0,
400            k: 1.5,
401            beta: 0.95,
402            gamma: 0.3,
403            theta_spike: 0.8,
404            x_threshold: 0.8,
405        }
406    }
407
408    pub fn step(&mut self, current: f64) -> i32 {
409        let x_prev = self.x;
410        let sig = 1.0 / (1.0 + (-(self.x - self.theta) * 4.0).exp());
411        let x_new = -self.x + self.k * sig + current;
412        let spiked = if self.x >= self.theta_spike { 1.0 } else { 0.0 };
413        let theta_new = self.beta * self.theta + self.gamma * spiked;
414
415        self.x = x_new.clamp(-5.0, 5.0);
416        self.theta = theta_new.clamp(-5.0, 5.0);
417
418        if !self.x.is_finite() {
419            self.x = 0.0;
420        }
421        if !self.theta.is_finite() {
422            self.theta = 0.0;
423        }
424
425        if self.x >= self.x_threshold && x_prev < self.x_threshold {
426            1
427        } else {
428            0
429        }
430    }
431
432    pub fn reset(&mut self) {
433        *self = Self::new();
434    }
435}
436
437/// Ermentrout-Kopell canonical Type I — theta neuron in map form.
438///
439/// The canonical model for Type I (saddle-node) excitability.
440/// theta(n+1) = theta(n) + dt * (1 - cos(theta)) + (1 + cos(theta)) * I
441/// Spike when theta crosses pi.
442///
443/// Ermentrout & Kopell, SIAM J Appl Math 46:233, 1986.
444#[derive(Clone, Debug)]
445pub struct ErmentroutKopellMapNeuron {
446    pub theta: f64, // Phase variable [0, 2*pi)
447    pub dt: f64,
448    pub gain: f64,
449    pub theta_threshold: f64,
450}
451
452impl Default for ErmentroutKopellMapNeuron {
453    fn default() -> Self {
454        Self::new()
455    }
456}
457
458impl ErmentroutKopellMapNeuron {
459    pub fn new() -> Self {
460        Self {
461            theta: 0.0,
462            dt: 0.1, // Discrete step size
463            gain: 1.0,
464            theta_threshold: std::f64::consts::PI,
465        }
466    }
467
468    pub fn step(&mut self, current: f64) -> i32 {
469        let input = self.gain * current;
470        let theta_prev = self.theta;
471
472        let d_theta = (1.0 - self.theta.cos()) + (1.0 + self.theta.cos()) * input;
473        self.theta += self.dt * d_theta;
474
475        // Spike detection: crossing pi
476        let fired = if self.theta >= self.theta_threshold && theta_prev < self.theta_threshold {
477            1
478        } else {
479            0
480        };
481
482        // Wrap theta to [0, 2*pi)
483        let two_pi = 2.0 * std::f64::consts::PI;
484        if self.theta >= two_pi {
485            self.theta -= two_pi;
486        }
487        if self.theta < 0.0 {
488            self.theta += two_pi;
489        }
490
491        if !self.theta.is_finite() {
492            self.theta = 0.0;
493        }
494
495        fired
496    }
497
498    pub fn reset(&mut self) {
499        *self = Self::new();
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn chialvo_fires() {
509        let mut n = ChialvoMapNeuron::new();
510        let t: i32 = (0..1000).map(|_| n.step(1.0)).sum();
511        assert!(t > 0);
512    }
513    #[test]
514    fn rulkov_fires() {
515        let mut n = RulkovMapNeuron::new();
516        let t: i32 = (0..2000).map(|_| n.step(0.5)).sum();
517        assert!(t > 0);
518    }
519    #[test]
520    fn ibarz_fires() {
521        let mut n = IbarzTanakaMapNeuron::new();
522        let t: i32 = (0..2000).map(|_| n.step(2.0)).sum();
523        assert!(t > 0);
524    }
525    #[test]
526    fn medvedev_fires() {
527        let mut n = MedvedevMapNeuron {
528            x: 0.5,
529            ..Default::default()
530        };
531        let t: i32 = (0..500).map(|_| n.step(0.1)).sum();
532        assert!(t > 0);
533    }
534    #[test]
535    fn cazelles_fires() {
536        let mut n = CazellesMapNeuron::new();
537        let t: i32 = (0..200).map(|_| n.step(0.0)).sum();
538        assert!(t > 0);
539    }
540    #[test]
541    fn cournekorkin_fires() {
542        let mut n = CourageNekorkinMapNeuron::new();
543        let t: i32 = (0..200).map(|_| n.step(0.5)).sum();
544        assert!(t > 0);
545    }
546
547    // -- Aihara Map coverage tests --
548
549    #[test]
550    fn aihara_fires_with_input() {
551        let mut n = AiharaMapNeuron::new();
552        let t: i32 = (0..2000).map(|_| n.step(1.0)).sum();
553        assert!(t > 0, "Aihara must fire with input, got {t}");
554    }
555
556    #[test]
557    fn aihara_silent_without_input() {
558        let mut n = AiharaMapNeuron::new();
559        let t: i32 = (0..5000).map(|_| n.step(0.0)).sum();
560        assert_eq!(t, 0, "Aihara must be silent without input, got {t}");
561    }
562
563    #[test]
564    fn aihara_chaotic_dynamics() {
565        // With appropriate input, trajectory should not settle to fixed point
566        let mut n = AiharaMapNeuron::new();
567        let mut values = Vec::new();
568        for _ in 0..1000 {
569            n.step(0.5);
570            values.push(n.x);
571        }
572        let mean = values.iter().sum::<f64>() / values.len() as f64;
573        let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
574        assert!(
575            var > 0.001,
576            "Trajectory should show variability (chaos), var={var}"
577        );
578    }
579
580    #[test]
581    fn aihara_negative_input_no_crash() {
582        let mut n = AiharaMapNeuron::new();
583        for _ in 0..10_000 {
584            n.step(-100.0);
585        }
586        assert!(n.x.is_finite());
587    }
588
589    #[test]
590    fn aihara_nan_input_stays_finite() {
591        let mut n = AiharaMapNeuron::new();
592        n.step(f64::NAN);
593        assert!(n.x.is_finite());
594    }
595
596    #[test]
597    fn aihara_extreme_input_bounded() {
598        let mut n = AiharaMapNeuron::new();
599        for _ in 0..1000 {
600            n.step(1e6);
601        }
602        assert!(n.x.is_finite() && n.x <= 1e6);
603    }
604
605    #[test]
606    fn aihara_reset_clears_state() {
607        let mut n = AiharaMapNeuron::new();
608        for _ in 0..100 {
609            n.step(1.0);
610        }
611        n.reset();
612        assert_eq!(n.x, 0.0);
613        assert_eq!(n.y, 0.0);
614    }
615
616    #[test]
617    fn aihara_rate_increases_with_input() {
618        let mut low = AiharaMapNeuron::new();
619        let mut high = AiharaMapNeuron::new();
620        let spikes_low: i32 = (0..5000).map(|_| low.step(0.5)).sum();
621        let spikes_high: i32 = (0..5000).map(|_| high.step(2.0)).sum();
622        assert!(
623            spikes_high >= spikes_low,
624            "Higher input should produce more spikes: high={spikes_high} vs low={spikes_low}"
625        );
626    }
627
628    #[test]
629    fn aihara_performance_100k_steps() {
630        let start = std::time::Instant::now();
631        let mut n = AiharaMapNeuron::new();
632        for _ in 0..100_000 {
633            std::hint::black_box(n.step(0.5));
634        }
635        let elapsed = start.elapsed();
636        assert!(
637            elapsed.as_millis() < 50,
638            "100k steps must complete in <50ms"
639        );
640    }
641
642    // -- Kilinc-Bhatt Map coverage tests --
643
644    #[test]
645    fn kb_fires_with_input() {
646        let mut n = KilincBhattMapNeuron::new();
647        let t: i32 = (0..5000).map(|_| n.step(1.0)).sum();
648        assert!(t > 0, "KB must fire with input, got {t}");
649    }
650
651    #[test]
652    fn kb_silent_without_input() {
653        let mut n = KilincBhattMapNeuron::new();
654        let t: i32 = (0..5000).map(|_| n.step(0.0)).sum();
655        assert_eq!(t, 0, "KB must be silent without input, got {t}");
656    }
657
658    #[test]
659    fn kb_adaptation() {
660        // Theta increases with spiking → fewer spikes over time
661        let mut n = KilincBhattMapNeuron::new();
662        let early: i32 = (0..2000).map(|_| n.step(1.0)).sum();
663        let late: i32 = (0..2000).map(|_| n.step(1.0)).sum();
664        assert!(
665            early >= late,
666            "Adaptation should slow firing: early={early}, late={late}"
667        );
668    }
669
670    #[test]
671    fn kb_theta_increases_during_spiking() {
672        let mut n = KilincBhattMapNeuron::new();
673        let theta_before = n.theta;
674        for _ in 0..5000 {
675            n.step(1.5);
676        }
677        assert!(
678            n.theta > theta_before,
679            "Theta must increase during spiking, theta={}",
680            n.theta
681        );
682    }
683
684    #[test]
685    fn kb_negative_input_no_crash() {
686        let mut n = KilincBhattMapNeuron::new();
687        for _ in 0..10_000 {
688            n.step(-100.0);
689        }
690        assert!(n.x.is_finite());
691    }
692
693    #[test]
694    fn kb_nan_input_stays_finite() {
695        let mut n = KilincBhattMapNeuron::new();
696        n.step(f64::NAN);
697        assert!(n.x.is_finite());
698    }
699
700    #[test]
701    fn kb_extreme_input_bounded() {
702        let mut n = KilincBhattMapNeuron::new();
703        for _ in 0..1000 {
704            n.step(1e6);
705        }
706        assert!(n.x.is_finite() && n.x <= 5.0);
707    }
708
709    #[test]
710    fn kb_reset_clears_state() {
711        let mut n = KilincBhattMapNeuron::new();
712        for _ in 0..100 {
713            n.step(1.0);
714        }
715        n.reset();
716        assert_eq!(n.x, 0.0);
717        assert_eq!(n.theta, 0.0);
718    }
719
720    #[test]
721    fn kb_performance_100k_steps() {
722        let start = std::time::Instant::now();
723        let mut n = KilincBhattMapNeuron::new();
724        for _ in 0..100_000 {
725            std::hint::black_box(n.step(0.5));
726        }
727        let elapsed = start.elapsed();
728        assert!(
729            elapsed.as_millis() < 50,
730            "100k steps must complete in <50ms"
731        );
732    }
733
734    // -- Ermentrout-Kopell Map coverage tests --
735
736    #[test]
737    fn ek_fires_with_input() {
738        let mut n = ErmentroutKopellMapNeuron::new();
739        let t: i32 = (0..5000).map(|_| n.step(0.5)).sum();
740        assert!(t > 0, "EK must fire with input, got {t}");
741    }
742
743    #[test]
744    fn ek_silent_without_input() {
745        // Type I: no firing below threshold (I < 0 is subthreshold for theta model)
746        let mut n = ErmentroutKopellMapNeuron::new();
747        let t: i32 = (0..5000).map(|_| n.step(-0.1)).sum();
748        assert_eq!(t, 0, "EK must be silent with negative input, got {t}");
749    }
750
751    #[test]
752    fn ek_type_i_excitability() {
753        // Type I: arbitrarily low firing rate near threshold
754        let mut n_low = ErmentroutKopellMapNeuron::new();
755        let mut n_high = ErmentroutKopellMapNeuron::new();
756        let spikes_low: i32 = (0..10_000).map(|_| n_low.step(0.01)).sum();
757        let spikes_high: i32 = (0..10_000).map(|_| n_high.step(1.0)).sum();
758        assert!(
759            spikes_high > spikes_low,
760            "Higher input → higher rate: high={spikes_high} vs low={spikes_low}"
761        );
762    }
763
764    #[test]
765    fn ek_theta_wraps() {
766        // Theta should stay in [0, 2*pi)
767        let mut n = ErmentroutKopellMapNeuron::new();
768        for _ in 0..10_000 {
769            n.step(0.5);
770        }
771        let two_pi = 2.0 * std::f64::consts::PI;
772        assert!(
773            n.theta >= 0.0 && n.theta < two_pi,
774            "Theta must wrap to [0, 2pi), theta={}",
775            n.theta
776        );
777    }
778
779    #[test]
780    fn ek_negative_input_no_crash() {
781        let mut n = ErmentroutKopellMapNeuron::new();
782        for _ in 0..10_000 {
783            n.step(-100.0);
784        }
785        assert!(n.theta.is_finite());
786    }
787
788    #[test]
789    fn ek_nan_input_stays_finite() {
790        let mut n = ErmentroutKopellMapNeuron::new();
791        n.step(f64::NAN);
792        assert!(n.theta.is_finite());
793    }
794
795    #[test]
796    fn ek_extreme_input_bounded() {
797        let mut n = ErmentroutKopellMapNeuron::new();
798        for _ in 0..1000 {
799            n.step(1e6);
800        }
801        assert!(n.theta.is_finite());
802    }
803
804    #[test]
805    fn ek_reset_clears_state() {
806        let mut n = ErmentroutKopellMapNeuron::new();
807        for _ in 0..100 {
808            n.step(0.5);
809        }
810        n.reset();
811        assert_eq!(n.theta, 0.0);
812    }
813
814    #[test]
815    fn ek_performance_100k_steps() {
816        let start = std::time::Instant::now();
817        let mut n = ErmentroutKopellMapNeuron::new();
818        for _ in 0..100_000 {
819            std::hint::black_box(n.step(0.5));
820        }
821        let elapsed = start.elapsed();
822        assert!(
823            elapsed.as_millis() < 50,
824            "100k steps must complete in <50ms"
825        );
826    }
827
828    // -- Courbage-Nekorkin coverage tests (extending existing) --
829
830    #[test]
831    fn cn_silent_without_input() {
832        let mut n = CourageNekorkinMapNeuron::new();
833        let _t: i32 = (0..5000).map(|_| n.step(0.0)).sum();
834        // May fire due to j=0.1 bias — model-specific
835        assert!(n.x.is_finite());
836    }
837
838    #[test]
839    fn cn_negative_input_no_crash() {
840        let mut n = CourageNekorkinMapNeuron::new();
841        for _ in 0..10_000 {
842            n.step(-100.0);
843        }
844        assert!(n.x.is_finite());
845        assert!(n.x >= -1e6);
846    }
847
848    #[test]
849    fn cn_nan_input_stays_finite() {
850        let mut n = CourageNekorkinMapNeuron::new();
851        n.step(f64::NAN);
852        assert!(n.x.is_finite());
853    }
854
855    #[test]
856    fn cn_extreme_input_bounded() {
857        let mut n = CourageNekorkinMapNeuron::new();
858        for _ in 0..1000 {
859            n.step(1e6);
860        }
861        assert!(n.x.is_finite() && n.x <= 1e6);
862    }
863
864    #[test]
865    fn cn_reset_clears_state() {
866        let mut n = CourageNekorkinMapNeuron::new();
867        for _ in 0..100 {
868            n.step(0.5);
869        }
870        n.reset();
871        assert_eq!(n.x, 0.0);
872        assert_eq!(n.y, 0.0);
873    }
874
875    #[test]
876    fn cn_saturation_function() {
877        // f(x) = alpha*x for x<0, alpha*x/(1+alpha*x) for x>=0
878        let mut n = CourageNekorkinMapNeuron::new();
879        n.x = 0.5;
880        // f(0.5) = 3*0.5/(1+3*0.5) = 1.5/2.5 = 0.6
881        let _ = n.step(0.0);
882        assert!(n.x.is_finite());
883    }
884
885    #[test]
886    fn cn_rate_increases_with_input() {
887        let mut low = CourageNekorkinMapNeuron::new();
888        let mut high = CourageNekorkinMapNeuron::new();
889        let sp_low: i32 = (0..5000).map(|_| low.step(0.0)).sum();
890        let sp_high: i32 = (0..5000).map(|_| high.step(1.0)).sum();
891        assert!(
892            sp_high >= sp_low,
893            "Higher input should fire more: high={sp_high} vs low={sp_low}"
894        );
895    }
896
897    #[test]
898    fn cn_performance_100k_steps() {
899        let start = std::time::Instant::now();
900        let mut n = CourageNekorkinMapNeuron::new();
901        for _ in 0..100_000 {
902            std::hint::black_box(n.step(0.5));
903        }
904        let elapsed = start.elapsed();
905        assert!(
906            elapsed.as_millis() < 50,
907            "100k steps must complete in <50ms"
908        );
909    }
910}