Skip to main content

sc_neurocore_engine/neurons/
population.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 — Population and Mean-Field Neuron Models
8
9//! Population-level and mean-field neuron models.
10//!
11//! Population and mean-field model group: Montbrio-Pazo-Roxin, Brunel balanced network,
12//! Tsodyks-Uziel-Markram, El Boustani network.
13//! Added one by one with full 7-point checklist verification.
14
15// ═══════════════════════════════════════════════════════════════════
16// Montbrio-Pazo-Roxin (MPR) Mean-Field
17// ═══════════════════════════════════════════════════════════════════
18
19/// Montbrio-Pazo-Roxin 2015 — exact mean-field of QIF neuron population.
20///
21/// Reduces an infinite population of quadratic integrate-and-fire (QIF)
22/// neurons to 2 ODEs for the population firing rate r and mean membrane
23/// potential v. This is mathematically exact (not an approximation) for
24/// Lorentzian-distributed heterogeneity.
25///
26/// dr/dt = (delta / (pi * tau^2)) + (2 * r * v / tau)
27/// dv/dt = (v^2 + eta + I - (pi * tau * r)^2) / tau
28///
29/// where delta = heterogeneity width, eta = mean excitability,
30/// tau = membrane time constant, I = external input.
31///
32/// Spike emitted when r exceeds a threshold (proxy for population burst).
33///
34/// Montbrio, Pazo & Roxin, Phys Rev X 5:021028, 2015.
35#[derive(Clone, Debug)]
36pub struct MontbrioMeanField {
37    pub r: f64,     // Population firing rate (Hz)
38    pub v: f64,     // Mean membrane potential
39    pub delta: f64, // Heterogeneity width (Lorentzian)
40    pub eta: f64,   // Mean excitability
41    pub tau: f64,   // Membrane time constant (ms)
42    pub j: f64,     // Synaptic coupling strength
43    pub dt: f64,
44    pub r_threshold: f64,
45    pub gain: f64,
46}
47
48impl Default for MontbrioMeanField {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl MontbrioMeanField {
55    pub fn new() -> Self {
56        Self {
57            r: 0.01,
58            v: -2.0,
59            delta: 1.0,
60            eta: -5.0, // Below threshold for spontaneous activity
61            tau: 1.0,
62            j: 15.0,  // Excitatory coupling
63            dt: 0.01, // Small dt for stability
64            r_threshold: 0.5,
65            gain: 1.0,
66        }
67    }
68
69    pub fn step(&mut self, current: f64) -> i32 {
70        let input = self.gain * current;
71        let r_prev = self.r;
72
73        let pi = std::f64::consts::PI;
74        let tau = self.tau;
75
76        // MPR equations with synaptic coupling (j * r adds recurrence)
77        let dr = (self.delta / (pi * tau * tau)) + (2.0 * self.r * self.v / tau);
78        let dv = (self.v * self.v + self.eta + input + self.j * tau * self.r
79            - (pi * tau * self.r).powi(2))
80            / tau;
81
82        self.r += self.dt * dr;
83        self.v += self.dt * dv;
84
85        // Safety bounds
86        self.r = self.r.clamp(0.0, 100.0);
87        self.v = self.v.clamp(-50.0, 50.0);
88        if !self.r.is_finite() {
89            self.r = 0.01;
90        }
91        if !self.v.is_finite() {
92            self.v = -2.0;
93        }
94
95        // "Spike" = population burst: r crosses threshold
96        if self.r >= self.r_threshold && r_prev < self.r_threshold {
97            1
98        } else {
99            0
100        }
101    }
102
103    pub fn reset(&mut self) {
104        *self = Self::new();
105    }
106}
107
108// ═══════════════════════════════════════════════════════════════════
109// Brunel Balanced Network
110// ═══════════════════════════════════════════════════════════════════
111
112/// Brunel 2000 — balanced excitatory/inhibitory network mean-field.
113///
114/// Two coupled rate equations for excitatory (r_e) and inhibitory (r_i)
115/// populations. The balance of E and I determines the dynamical regime:
116/// - Asynchronous Irregular (AI): low rates, Poisson-like
117/// - Synchronous Regular (SR): oscillatory, gamma-band
118/// - Synchronous Irregular (SI): fast oscillations, irregular single units
119///
120/// tau_e * dr_e/dt = -r_e + phi(J_ee*r_e - J_ei*r_i + I_ext)
121/// tau_i * dr_i/dt = -r_i + phi(J_ie*r_e - J_ii*r_i)
122///
123/// phi() is a threshold-linear transfer function.
124///
125/// Brunel, J Comput Neurosci 8:183, 2000.
126#[derive(Clone, Debug)]
127pub struct BrunelNetwork {
128    pub r_e: f64,       // Excitatory rate (Hz)
129    pub r_i: f64,       // Inhibitory rate (Hz)
130    pub tau_e: f64,     // Excitatory time constant (ms)
131    pub tau_i: f64,     // Inhibitory time constant (ms)
132    pub j_ee: f64,      // E→E coupling
133    pub j_ei: f64,      // I→E coupling (inhibitory, positive value)
134    pub j_ie: f64,      // E→I coupling
135    pub j_ii: f64,      // I→I coupling (inhibitory, positive value)
136    pub threshold: f64, // Transfer function threshold
137    pub gain_phi: f64,  // Transfer function gain
138    pub dt: f64,
139    pub r_threshold: f64, // Spike detection threshold
140    pub gain: f64,
141}
142
143impl Default for BrunelNetwork {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149impl BrunelNetwork {
150    pub fn new() -> Self {
151        Self {
152            r_e: 0.1,
153            r_i: 0.1,
154            tau_e: 20.0,
155            tau_i: 10.0,
156            j_ee: 0.2,
157            j_ei: 0.8, // Strong I→E inhibition
158            j_ie: 0.5,
159            j_ii: 0.2,
160            threshold: 0.0,
161            gain_phi: 1.0,
162            dt: 0.1,
163            r_threshold: 1.0,
164            gain: 1.0,
165        }
166    }
167
168    /// Threshold-linear transfer function
169    fn phi(&self, x: f64) -> f64 {
170        if x > self.threshold {
171            self.gain_phi * (x - self.threshold)
172        } else {
173            0.0
174        }
175    }
176
177    pub fn step(&mut self, current: f64) -> i32 {
178        let input = self.gain * current;
179        let r_e_prev = self.r_e;
180
181        let drive_e = self.j_ee * self.r_e - self.j_ei * self.r_i + input;
182        let drive_i = self.j_ie * self.r_e - self.j_ii * self.r_i;
183
184        let dr_e = (-self.r_e + self.phi(drive_e)) / self.tau_e;
185        let dr_i = (-self.r_i + self.phi(drive_i)) / self.tau_i;
186
187        self.r_e += self.dt * dr_e;
188        self.r_i += self.dt * dr_i;
189
190        // Rates non-negative
191        if self.r_e < 0.0 {
192            self.r_e = 0.0;
193        }
194        if self.r_i < 0.0 {
195            self.r_i = 0.0;
196        }
197
198        // Safety bounds
199        if self.r_e > 200.0 {
200            self.r_e = 200.0;
201        }
202        if self.r_i > 200.0 {
203            self.r_i = 200.0;
204        }
205        if !self.r_e.is_finite() {
206            self.r_e = 0.1;
207        }
208        if !self.r_i.is_finite() {
209            self.r_i = 0.1;
210        }
211
212        // "Spike" when E rate crosses threshold
213        if self.r_e >= self.r_threshold && r_e_prev < self.r_threshold {
214            1
215        } else {
216            0
217        }
218    }
219
220    pub fn reset(&mut self) {
221        *self = Self::new();
222    }
223}
224
225// ═══════════════════════════════════════════════════════════════════
226// Tsodyks-Uziel-Markram (TUM) Network
227// ═══════════════════════════════════════════════════════════════════
228
229/// TUM 2000 — mean-field network with short-term synaptic plasticity.
230///
231/// Population rate equation coupled with Tsodyks-Markram STP dynamics
232/// (synaptic depression and facilitation). The effective synaptic weight
233/// is u*x*J, where u = utilisation (facilitation) and x = available
234/// resources (depression).
235///
236/// tau * dr/dt = -r + phi(u * x * J * r + I)
237/// dx/dt = (1 - x) / tau_d - u * x * r      (depression)
238/// du/dt = (U - u) / tau_f + U * (1 - u) * r (facilitation)
239///
240/// Tsodyks, Uziel & Markram, J Neurosci 20:RC50, 2000.
241#[derive(Clone, Debug)]
242pub struct TUMNetwork {
243    pub r: f64,      // Population rate
244    pub x: f64,      // Available synaptic resources [0, 1]
245    pub u: f64,      // Release probability (facilitation) [0, 1]
246    pub j: f64,      // Base synaptic strength
247    pub u_base: f64, // Baseline release probability
248    pub tau: f64,    // Rate time constant (ms)
249    pub tau_d: f64,  // Depression recovery (ms)
250    pub tau_f: f64,  // Facilitation decay (ms)
251    pub threshold: f64,
252    pub gain_phi: f64,
253    pub dt: f64,
254    pub r_threshold: f64,
255    pub gain: f64,
256}
257
258impl Default for TUMNetwork {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264impl TUMNetwork {
265    pub fn new() -> Self {
266        Self {
267            r: 0.1,
268            x: 1.0, // Full resources
269            u: 0.2, // Low initial utilisation
270            j: 5.0,
271            u_base: 0.2,
272            tau: 10.0,
273            tau_d: 200.0, // Slow depression recovery
274            tau_f: 50.0,  // Faster facilitation decay
275            threshold: 0.0,
276            gain_phi: 1.0,
277            dt: 0.1,
278            r_threshold: 1.0,
279            gain: 1.0,
280        }
281    }
282
283    fn phi(&self, x: f64) -> f64 {
284        if x > self.threshold {
285            self.gain_phi * (x - self.threshold)
286        } else {
287            0.0
288        }
289    }
290
291    pub fn step(&mut self, current: f64) -> i32 {
292        let input = self.gain * current;
293        let r_prev = self.r;
294
295        // STP dynamics
296        let dx = (1.0 - self.x) / self.tau_d - self.u * self.x * self.r;
297        let du = (self.u_base - self.u) / self.tau_f + self.u_base * (1.0 - self.u) * self.r;
298
299        self.x += self.dt * dx;
300        self.u += self.dt * du;
301
302        // Rate dynamics with STP-modulated coupling
303        let effective_j = self.u * self.x * self.j;
304        let dr = (-self.r + self.phi(effective_j * self.r + input)) / self.tau;
305        self.r += self.dt * dr;
306
307        // Bounds
308        self.r = self.r.clamp(0.0, 200.0);
309        self.x = self.x.clamp(0.0, 1.0);
310        self.u = self.u.clamp(0.0, 1.0);
311        if !self.r.is_finite() {
312            self.r = 0.1;
313        }
314        if !self.x.is_finite() {
315            self.x = 1.0;
316        }
317        if !self.u.is_finite() {
318            self.u = 0.2;
319        }
320
321        if self.r >= self.r_threshold && r_prev < self.r_threshold {
322            1
323        } else {
324            0
325        }
326    }
327
328    pub fn reset(&mut self) {
329        *self = Self::new();
330    }
331}
332
333// ═══════════════════════════════════════════════════════════════════
334// El Boustani Network
335// ═══════════════════════════════════════════════════════════════════
336
337/// El Boustani & Bhatt 2009 — E/I network with NMDA-mediated bistability.
338///
339/// Two-population (E/I) mean-field with separate fast (AMPA) and slow
340/// (NMDA) excitatory components. NMDA provides the recurrent excitation
341/// needed for working memory persistent activity (bistability).
342///
343/// tau_e * dr_e/dt = -r_e + phi(J_ampa*r_e + J_nmda*s + I - J_ei*r_i)
344/// tau_i * dr_i/dt = -r_i + phi(J_ie*r_e - J_ii*r_i)
345/// tau_s * ds/dt = -s + gamma * r_e * (1 - s)
346///
347/// El Boustani & Bhatt, J Comput Neurosci 26:313, 2009.
348#[derive(Clone, Debug)]
349pub struct ElBoustaniNetwork {
350    pub r_e: f64,
351    pub r_i: f64,
352    pub s: f64, // NMDA synaptic gating variable
353    pub tau_e: f64,
354    pub tau_i: f64,
355    pub tau_s: f64,  // NMDA decay (~100 ms)
356    pub j_ampa: f64, // Fast E→E (AMPA)
357    pub j_nmda: f64, // Slow E→E (NMDA)
358    pub j_ei: f64,
359    pub j_ie: f64,
360    pub j_ii: f64,
361    pub gamma: f64, // NMDA saturation rate
362    pub threshold: f64,
363    pub gain_phi: f64,
364    pub dt: f64,
365    pub r_threshold: f64,
366    pub gain: f64,
367}
368
369impl Default for ElBoustaniNetwork {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375impl ElBoustaniNetwork {
376    pub fn new() -> Self {
377        Self {
378            r_e: 0.1,
379            r_i: 0.1,
380            s: 0.0,
381            tau_e: 20.0,
382            tau_i: 10.0,
383            tau_s: 100.0,
384            j_ampa: 0.1,
385            j_nmda: 0.5,
386            j_ei: 0.8,
387            j_ie: 0.5,
388            j_ii: 0.2,
389            gamma: 0.641, // NMDA saturation parameter
390            threshold: 0.0,
391            gain_phi: 1.0,
392            dt: 0.1,
393            r_threshold: 1.0,
394            gain: 1.0,
395        }
396    }
397
398    fn phi(&self, x: f64) -> f64 {
399        if x > self.threshold {
400            self.gain_phi * (x - self.threshold)
401        } else {
402            0.0
403        }
404    }
405
406    pub fn step(&mut self, current: f64) -> i32 {
407        let input = self.gain * current;
408        let r_e_prev = self.r_e;
409
410        // NMDA gating dynamics
411        let ds = (-self.s + self.gamma * self.r_e * (1.0 - self.s)) / self.tau_s;
412        self.s += self.dt * ds;
413
414        // E and I rate dynamics
415        let drive_e = self.j_ampa * self.r_e + self.j_nmda * self.s - self.j_ei * self.r_i + input;
416        let drive_i = self.j_ie * self.r_e - self.j_ii * self.r_i;
417
418        let dr_e = (-self.r_e + self.phi(drive_e)) / self.tau_e;
419        let dr_i = (-self.r_i + self.phi(drive_i)) / self.tau_i;
420
421        self.r_e += self.dt * dr_e;
422        self.r_i += self.dt * dr_i;
423
424        // Bounds
425        self.r_e = self.r_e.clamp(0.0, 200.0);
426        self.r_i = self.r_i.clamp(0.0, 200.0);
427        self.s = self.s.clamp(0.0, 1.0);
428        if !self.r_e.is_finite() {
429            self.r_e = 0.1;
430        }
431        if !self.r_i.is_finite() {
432            self.r_i = 0.1;
433        }
434        if !self.s.is_finite() {
435            self.s = 0.0;
436        }
437
438        if self.r_e >= self.r_threshold && r_e_prev < self.r_threshold {
439            1
440        } else {
441            0
442        }
443    }
444
445    pub fn reset(&mut self) {
446        *self = Self::new();
447    }
448}
449
450// ═══════════════════════════════════════════════════════════════════
451// Tests
452// ═══════════════════════════════════════════════════════════════════
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn mpr_fires_with_input() {
460        let mut n = MontbrioMeanField::new();
461        let mut spikes = 0;
462        for _ in 0..50_000 {
463            spikes += n.step(10.0);
464        }
465        assert!(
466            spikes > 0,
467            "MPR must produce bursts with strong input, got {spikes}"
468        );
469    }
470
471    #[test]
472    fn mpr_silent_without_input() {
473        // eta = -5 (below threshold), no input → quiescent
474        let mut n = MontbrioMeanField::new();
475        let mut spikes = 0;
476        for _ in 0..50_000 {
477            spikes += n.step(0.0);
478        }
479        assert_eq!(
480            spikes, 0,
481            "MPR must be quiescent without input (eta<0), got {spikes}"
482        );
483    }
484
485    #[test]
486    fn mpr_rate_increases_with_input() {
487        let mut low = MontbrioMeanField::new();
488        let mut high = MontbrioMeanField::new();
489        for _ in 0..10_000 {
490            low.step(3.0);
491            high.step(15.0);
492        }
493        assert!(
494            high.r > low.r,
495            "Higher input → higher rate: high={:.3} vs low={:.3}",
496            high.r,
497            low.r
498        );
499    }
500
501    #[test]
502    fn mpr_two_ode_dynamics() {
503        // Verify both r and v evolve from initial conditions
504        let mut n = MontbrioMeanField::new();
505        let r0 = n.r;
506        let v0 = n.v;
507        for _ in 0..1000 {
508            n.step(5.0);
509        }
510        assert!(
511            n.r != r0 || n.v != v0,
512            "State must evolve from initial conditions"
513        );
514    }
515
516    #[test]
517    fn mpr_rate_non_negative() {
518        let mut n = MontbrioMeanField::new();
519        for _ in 0..50_000 {
520            n.step(-10.0);
521        }
522        assert!(n.r >= 0.0, "Rate must be non-negative, r={}", n.r);
523    }
524
525    #[test]
526    fn mpr_negative_input_no_crash() {
527        let mut n = MontbrioMeanField::new();
528        for _ in 0..50_000 {
529            n.step(-100.0);
530        }
531        assert!(n.r.is_finite());
532        assert!(n.v.is_finite());
533    }
534
535    #[test]
536    fn mpr_nan_input_stays_finite() {
537        let mut n = MontbrioMeanField::new();
538        n.step(f64::NAN);
539        assert!(n.r.is_finite());
540        assert!(n.v.is_finite());
541    }
542
543    #[test]
544    fn mpr_extreme_input_bounded() {
545        let mut n = MontbrioMeanField::new();
546        for _ in 0..10_000 {
547            n.step(1e6);
548        }
549        assert!(n.r.is_finite() && n.r <= 100.0);
550        assert!(n.v.is_finite() && n.v <= 50.0);
551    }
552
553    #[test]
554    fn mpr_reset_clears_state() {
555        let mut n = MontbrioMeanField::new();
556        for _ in 0..10_000 {
557            n.step(10.0);
558        }
559        n.reset();
560        assert_eq!(n.r, 0.01);
561        assert_eq!(n.v, -2.0);
562    }
563
564    #[test]
565    fn mpr_performance_100k_steps() {
566        let start = std::time::Instant::now();
567        let mut n = MontbrioMeanField::new();
568        for _ in 0..100_000 {
569            std::hint::black_box(n.step(5.0));
570        }
571        let elapsed = start.elapsed();
572        assert!(
573            elapsed.as_millis() < 50,
574            "100k steps must complete in <50ms"
575        );
576    }
577
578    // -- Brunel Balanced Network tests --
579
580    #[test]
581    fn brunel_fires_with_input() {
582        let mut n = BrunelNetwork::new();
583        let mut spikes = 0;
584        for _ in 0..50_000 {
585            spikes += n.step(5.0);
586        }
587        assert!(
588            spikes > 0,
589            "Brunel must produce bursts with input, got {spikes}"
590        );
591    }
592
593    #[test]
594    fn brunel_silent_without_input() {
595        let mut n = BrunelNetwork::new();
596        let mut spikes = 0;
597        for _ in 0..50_000 {
598            spikes += n.step(0.0);
599        }
600        assert_eq!(
601            spikes, 0,
602            "Brunel must be quiescent without input, got {spikes}"
603        );
604    }
605
606    #[test]
607    fn brunel_ei_balance() {
608        // Strong inhibition keeps E rate bounded
609        let mut n = BrunelNetwork::new();
610        for _ in 0..50_000 {
611            n.step(3.0);
612        }
613        assert!(
614            n.r_e < 50.0,
615            "E/I balance should keep r_e bounded, r_e={}",
616            n.r_e
617        );
618        assert!(n.r_i >= 0.0, "r_i must be non-negative");
619    }
620
621    #[test]
622    fn brunel_inhibition_suppresses() {
623        // Increasing j_ei should reduce E rate
624        let mut weak_inh = BrunelNetwork::new();
625        weak_inh.j_ei = 0.3;
626        let mut strong_inh = BrunelNetwork::new();
627        strong_inh.j_ei = 2.0;
628
629        for _ in 0..20_000 {
630            weak_inh.step(5.0);
631            strong_inh.step(5.0);
632        }
633        assert!(
634            weak_inh.r_e >= strong_inh.r_e,
635            "Stronger inhibition → lower E rate: weak={:.2} vs strong={:.2}",
636            weak_inh.r_e,
637            strong_inh.r_e
638        );
639    }
640
641    #[test]
642    fn brunel_rates_non_negative() {
643        let mut n = BrunelNetwork::new();
644        for _ in 0..50_000 {
645            n.step(-10.0);
646        }
647        assert!(n.r_e >= 0.0);
648        assert!(n.r_i >= 0.0);
649    }
650
651    #[test]
652    fn brunel_negative_input_no_crash() {
653        let mut n = BrunelNetwork::new();
654        for _ in 0..50_000 {
655            n.step(-100.0);
656        }
657        assert!(n.r_e.is_finite());
658        assert!(n.r_i.is_finite());
659    }
660
661    #[test]
662    fn brunel_nan_input_stays_finite() {
663        let mut n = BrunelNetwork::new();
664        n.step(f64::NAN);
665        assert!(n.r_e.is_finite());
666        assert!(n.r_i.is_finite());
667    }
668
669    #[test]
670    fn brunel_extreme_input_bounded() {
671        let mut n = BrunelNetwork::new();
672        for _ in 0..10_000 {
673            n.step(1e6);
674        }
675        assert!(n.r_e.is_finite() && n.r_e <= 200.0);
676    }
677
678    #[test]
679    fn brunel_reset_clears_state() {
680        let mut n = BrunelNetwork::new();
681        for _ in 0..10_000 {
682            n.step(5.0);
683        }
684        n.reset();
685        assert_eq!(n.r_e, 0.1);
686        assert_eq!(n.r_i, 0.1);
687    }
688
689    #[test]
690    fn brunel_performance_100k_steps() {
691        let start = std::time::Instant::now();
692        let mut n = BrunelNetwork::new();
693        for _ in 0..100_000 {
694            std::hint::black_box(n.step(3.0));
695        }
696        let elapsed = start.elapsed();
697        assert!(
698            elapsed.as_millis() < 50,
699            "100k steps must complete in <50ms"
700        );
701    }
702
703    // -- TUM Network tests --
704
705    #[test]
706    fn tum_fires_with_input() {
707        let mut n = TUMNetwork::new();
708        let mut spikes = 0;
709        for _ in 0..50_000 {
710            spikes += n.step(5.0);
711        }
712        assert!(
713            spikes > 0,
714            "TUM must produce bursts with input, got {spikes}"
715        );
716    }
717
718    #[test]
719    fn tum_silent_without_input() {
720        let mut n = TUMNetwork::new();
721        let mut spikes = 0;
722        for _ in 0..50_000 {
723            spikes += n.step(0.0);
724        }
725        assert_eq!(
726            spikes, 0,
727            "TUM must be quiescent without input, got {spikes}"
728        );
729    }
730
731    #[test]
732    fn tum_depression_reduces_rate() {
733        // With sustained activity, synaptic resources (x) deplete → effective
734        // coupling drops → rate should decrease relative to initial transient
735        let mut n = TUMNetwork::new();
736        // Drive to steady state
737        for _ in 0..20_000 {
738            n.step(8.0);
739        }
740        let r_sustained = n.r;
741        let x_depleted = n.x;
742        assert!(
743            x_depleted < 0.9,
744            "Sustained activity should deplete resources, x={x_depleted}"
745        );
746        // Reset and measure transient (fresh resources)
747        n.reset();
748        for _ in 0..500 {
749            n.step(8.0);
750        }
751        let r_transient = n.r;
752        // Transient may be higher because x=1.0 initially
753        // The key test is that x was depleted under sustained drive
754        assert!(n.x < 1.0, "Resources should start depleting");
755        // Log for debugging
756        let _ = (r_transient, r_sustained);
757    }
758
759    #[test]
760    fn tum_facilitation_builds() {
761        // With repeated activation, u (utilisation) should increase from baseline
762        let mut n = TUMNetwork::new();
763        let u0 = n.u;
764        for _ in 0..5_000 {
765            n.step(5.0);
766        }
767        assert!(
768            n.u > u0,
769            "Facilitation should increase u: u0={u0}, u_now={}",
770            n.u
771        );
772    }
773
774    #[test]
775    fn tum_stp_modulates_coupling() {
776        // Effective coupling u*x*J changes with activity
777        let mut n = TUMNetwork::new();
778        let eff_0 = n.u * n.x * n.j;
779        for _ in 0..10_000 {
780            n.step(5.0);
781        }
782        let eff_1 = n.u * n.x * n.j;
783        assert!(
784            (eff_0 - eff_1).abs() > 0.01,
785            "STP must modulate effective coupling: eff_0={eff_0:.3}, eff_1={eff_1:.3}"
786        );
787    }
788
789    #[test]
790    fn tum_rate_non_negative() {
791        let mut n = TUMNetwork::new();
792        for _ in 0..50_000 {
793            n.step(-10.0);
794        }
795        assert!(n.r >= 0.0, "Rate must be non-negative, r={}", n.r);
796    }
797
798    #[test]
799    fn tum_nan_input_stays_finite() {
800        let mut n = TUMNetwork::new();
801        n.step(f64::NAN);
802        assert!(n.r.is_finite());
803        assert!(n.x.is_finite());
804        assert!(n.u.is_finite());
805    }
806
807    #[test]
808    fn tum_extreme_input_bounded() {
809        let mut n = TUMNetwork::new();
810        for _ in 0..10_000 {
811            n.step(1e6);
812        }
813        assert!(n.r.is_finite() && n.r <= 200.0);
814        assert!(n.x >= 0.0 && n.x <= 1.0);
815        assert!(n.u >= 0.0 && n.u <= 1.0);
816    }
817
818    #[test]
819    fn tum_reset_clears_state() {
820        let mut n = TUMNetwork::new();
821        for _ in 0..10_000 {
822            n.step(5.0);
823        }
824        n.reset();
825        assert_eq!(n.r, 0.1);
826        assert_eq!(n.x, 1.0);
827        assert_eq!(n.u, 0.2);
828    }
829
830    #[test]
831    fn tum_performance_100k_steps() {
832        let start = std::time::Instant::now();
833        let mut n = TUMNetwork::new();
834        for _ in 0..100_000 {
835            std::hint::black_box(n.step(5.0));
836        }
837        let elapsed = start.elapsed();
838        assert!(
839            elapsed.as_millis() < 50,
840            "100k steps must complete in <50ms"
841        );
842    }
843
844    // -- El Boustani Network tests --
845
846    #[test]
847    fn elboustani_fires_with_input() {
848        let mut n = ElBoustaniNetwork::new();
849        let mut spikes = 0;
850        for _ in 0..50_000 {
851            spikes += n.step(5.0);
852        }
853        assert!(
854            spikes > 0,
855            "ElBoustani must produce bursts with input, got {spikes}"
856        );
857    }
858
859    #[test]
860    fn elboustani_silent_without_input() {
861        let mut n = ElBoustaniNetwork::new();
862        let mut spikes = 0;
863        for _ in 0..50_000 {
864            spikes += n.step(0.0);
865        }
866        assert_eq!(
867            spikes, 0,
868            "ElBoustani must be quiescent without input, got {spikes}"
869        );
870    }
871
872    #[test]
873    fn elboustani_nmda_builds_with_activity() {
874        // NMDA gating variable s should increase with sustained E activity
875        let mut n = ElBoustaniNetwork::new();
876        let s0 = n.s;
877        for _ in 0..10_000 {
878            n.step(5.0);
879        }
880        assert!(
881            n.s > s0,
882            "NMDA gating should increase with activity: s0={s0}, s_now={}",
883            n.s
884        );
885    }
886
887    #[test]
888    fn elboustani_ei_balance() {
889        // Inhibition should keep E rate bounded
890        let mut n = ElBoustaniNetwork::new();
891        for _ in 0..50_000 {
892            n.step(3.0);
893        }
894        assert!(
895            n.r_e < 50.0,
896            "E/I balance should keep r_e bounded, r_e={}",
897            n.r_e
898        );
899        assert!(n.r_i >= 0.0, "r_i must be non-negative");
900    }
901
902    #[test]
903    fn elboustani_nmda_enhances_excitation() {
904        // With NMDA (j_nmda > 0), E rate should be higher than without
905        let mut with_nmda = ElBoustaniNetwork::new();
906        let mut no_nmda = ElBoustaniNetwork::new();
907        no_nmda.j_nmda = 0.0;
908        for _ in 0..20_000 {
909            with_nmda.step(3.0);
910            no_nmda.step(3.0);
911        }
912        assert!(
913            with_nmda.r_e >= no_nmda.r_e,
914            "NMDA should enhance excitation: with={:.3} vs without={:.3}",
915            with_nmda.r_e,
916            no_nmda.r_e
917        );
918    }
919
920    #[test]
921    fn elboustani_nmda_bounded() {
922        // NMDA gating s must stay in [0, 1]
923        let mut n = ElBoustaniNetwork::new();
924        for _ in 0..50_000 {
925            n.step(10.0);
926        }
927        assert!(
928            n.s >= 0.0 && n.s <= 1.0,
929            "NMDA gating must be in [0,1], s={}",
930            n.s
931        );
932    }
933
934    #[test]
935    fn elboustani_rates_non_negative() {
936        let mut n = ElBoustaniNetwork::new();
937        for _ in 0..50_000 {
938            n.step(-10.0);
939        }
940        assert!(n.r_e >= 0.0);
941        assert!(n.r_i >= 0.0);
942    }
943
944    #[test]
945    fn elboustani_nan_input_stays_finite() {
946        let mut n = ElBoustaniNetwork::new();
947        n.step(f64::NAN);
948        assert!(n.r_e.is_finite());
949        assert!(n.r_i.is_finite());
950        assert!(n.s.is_finite());
951    }
952
953    #[test]
954    fn elboustani_extreme_input_bounded() {
955        let mut n = ElBoustaniNetwork::new();
956        for _ in 0..10_000 {
957            n.step(1e6);
958        }
959        assert!(n.r_e.is_finite() && n.r_e <= 200.0);
960        assert!(n.s >= 0.0 && n.s <= 1.0);
961    }
962
963    #[test]
964    fn elboustani_reset_clears_state() {
965        let mut n = ElBoustaniNetwork::new();
966        for _ in 0..10_000 {
967            n.step(5.0);
968        }
969        n.reset();
970        assert_eq!(n.r_e, 0.1);
971        assert_eq!(n.r_i, 0.1);
972        assert_eq!(n.s, 0.0);
973    }
974}