Skip to main content

sc_neurocore_engine/neurons/biophysical/
mainen_sejnowski.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 — Mainen-Sejnowski Neuron Model
8
9//! Mainen-Sejnowski two-compartment soma and axon dynamics.
10
11/// Mainen-Sejnowski — two-compartment (soma + axon). Mainen & Sejnowski 1996.
12#[derive(Clone, Debug)]
13pub struct MainenSejnowskiNeuron {
14    pub vs: f64,
15    pub va: f64,
16    pub m: f64,
17    pub h: f64,
18    pub n: f64,
19    pub kappa: f64,
20    pub g_na: f64,
21    pub g_k: f64,
22    pub g_l: f64,
23    pub e_na: f64,
24    pub e_k: f64,
25    pub e_l: f64,
26    pub c_s: f64,
27    pub c_a: f64,
28    pub dt: f64,
29    pub v_threshold: f64,
30    /// Legacy engine configuration: update the soma voltage first and let
31    /// the axon derivative consume the already-updated soma value
32    /// (Gauss-Seidel ordering). The canonical reference uses the Python
33    /// Jacobi ordering (both derivatives from pre-update values).
34    pub legacy_sequential: bool,
35}
36
37impl MainenSejnowskiNeuron {
38    pub fn new() -> Self {
39        Self {
40            vs: -65.0,
41            va: -65.0,
42            m: 0.05,
43            h: 0.6,
44            n: 0.3,
45            kappa: 10.0,
46            g_na: 3000.0,
47            g_k: 1500.0,
48            g_l: 1.0,
49            e_na: 50.0,
50            e_k: -90.0,
51            e_l: -70.0,
52            c_s: 1.0,
53            c_a: 0.1,
54            dt: 0.005,
55            v_threshold: -20.0,
56            legacy_sequential: false,
57        }
58    }
59
60    /// Reconstruct the original engine ordering (soma committed before the
61    /// axon derivative is evaluated). This is a legacy configuration of the
62    /// same model, not a separate catalogue identity.
63    pub fn new_legacy_sequential() -> Self {
64        let mut neuron = Self::new();
65        neuron.legacy_sequential = true;
66        neuron
67    }
68
69    fn valid(&self) -> bool {
70        let finite = [
71            self.vs,
72            self.va,
73            self.m,
74            self.h,
75            self.n,
76            self.kappa,
77            self.g_na,
78            self.g_k,
79            self.g_l,
80            self.e_na,
81            self.e_k,
82            self.e_l,
83            self.c_s,
84            self.c_a,
85            self.dt,
86            self.v_threshold,
87        ]
88        .into_iter()
89        .all(f64::is_finite);
90        finite
91            && (-200.0..=200.0).contains(&self.vs)
92            && (-200.0..=200.0).contains(&self.va)
93            && [self.m, self.h, self.n]
94                .into_iter()
95                .all(|gate| (0.0..=1.0).contains(&gate))
96            && (0.0..=100.0).contains(&self.kappa)
97            && (0.0..=5000.0).contains(&self.g_na)
98            && (0.0..=3000.0).contains(&self.g_k)
99            && (0.0..=5.0).contains(&self.g_l)
100            && (30.0..=70.0).contains(&self.e_na)
101            && (-100.0..=-70.0).contains(&self.e_k)
102            && (-90.0..=-50.0).contains(&self.e_l)
103            && (0.5..=2.0).contains(&self.c_s)
104            && (0.05..=1.0).contains(&self.c_a)
105            && self.dt > 0.0
106            && self.dt <= 0.1
107            && (-40.0..=20.0).contains(&self.v_threshold)
108    }
109
110    /// Evaluate `x / (1 - exp(-x / k))` with its analytic limit `k` at 0.
111    fn linoid(x: f64, k: f64) -> f64 {
112        if x == 0.0 {
113            k
114        } else {
115            x / -(-x / k).exp_m1()
116        }
117    }
118
119    /// Advance one step after validating the drive and configuration.
120    ///
121    /// Computes the whole update on a candidate clone and commits only on
122    /// success: a non-finite `current`, a configuration outside the public
123    /// bounds, or a non-finite candidate returns `Err` with the pre-step
124    /// state preserved exactly.
125    pub fn try_step(&mut self, current: f64) -> Result<i32, &'static str> {
126        if !current.is_finite() {
127            return Err("current must be finite");
128        }
129        if !self.valid() {
130            return Err("Mainen-Sejnowski state and parameters must satisfy the public bounds");
131        }
132
133        let mut candidate = self.clone();
134        let v_prev = candidate.vs;
135        for _ in 0..20 {
136            if candidate.legacy_sequential {
137                Self::legacy_sequential_substep(&mut candidate, current);
138            } else {
139                Self::canonical_substep(&mut candidate, current);
140            }
141
142            if ![
143                candidate.vs,
144                candidate.va,
145                candidate.m,
146                candidate.h,
147                candidate.n,
148            ]
149            .into_iter()
150            .all(f64::is_finite)
151            {
152                return Err("Mainen-Sejnowski candidate state became non-finite");
153            }
154        }
155
156        *self = candidate;
157        if self.vs >= self.v_threshold && v_prev < self.v_threshold {
158            Ok(1)
159        } else {
160            Ok(0)
161        }
162    }
163
164    /// Canonical reference sub-step: Jacobi compartment ordering with
165    /// analytically exact removable-singularity rate limits.
166    fn canonical_substep(candidate: &mut Self, current: f64) {
167        let va = candidate.va;
168        // Mainen & Sejnowski 1996 axonal rate functions (stable linoid form).
169        let am = 0.182 * Self::linoid(va + 25.0, 9.0);
170        let bm = 0.124 * Self::linoid(-(va + 25.0), 9.0);
171        let ah = 0.024 * Self::linoid(va + 40.0, 5.0);
172        let bh = 0.0091 * Self::linoid(-(va + 65.0), 5.0);
173        let an = 0.02 * Self::linoid(va - 20.0, 9.0);
174        let bn = 0.002 * Self::linoid(-(va - 20.0), 9.0);
175
176        candidate.m = (candidate.m + (am * (1.0 - candidate.m) - bm * candidate.m) * candidate.dt)
177            .clamp(0.0, 1.0);
178        candidate.h = (candidate.h + (ah * (1.0 - candidate.h) - bh * candidate.h) * candidate.dt)
179            .clamp(0.0, 1.0);
180        candidate.n = (candidate.n + (an * (1.0 - candidate.n) - bn * candidate.n) * candidate.dt)
181            .clamp(0.0, 1.0);
182
183        let i_na = candidate.g_na * candidate.m.powi(3) * candidate.h * (va - candidate.e_na);
184        let i_k = candidate.g_k * candidate.n * (va - candidate.e_k);
185        let i_l_s = candidate.g_l * (candidate.vs - candidate.e_l);
186
187        let dvs = (-i_l_s + candidate.kappa * (va - candidate.vs) + current) / candidate.c_s
188            * candidate.dt;
189        let dva =
190            (-i_na - i_k + candidate.kappa * (candidate.vs - va)) / candidate.c_a * candidate.dt;
191        candidate.vs = (candidate.vs + dvs).clamp(-200.0, 200.0);
192        candidate.va = (va + dva).clamp(-200.0, 200.0);
193    }
194
195    /// Legacy engine sub-step preserved verbatim: Gauss-Seidel ordering
196    /// (the axon derivative consumes the already-updated soma voltage) with
197    /// the original `|x| < 1e-6` analytic-limit branches and additive 1e-12
198    /// regularisation elsewhere.
199    fn legacy_sequential_substep(candidate: &mut Self, current: f64) {
200        let x_am = candidate.va + 25.0;
201        let am = if x_am.abs() < 1e-6 {
202            0.182 * 9.0
203        } else {
204            0.182 * x_am / (1.0 - (-(x_am) / 9.0).exp() + 1e-12)
205        };
206        let bm = if x_am.abs() < 1e-6 {
207            0.124 * 9.0
208        } else {
209            -0.124 * x_am / (1.0 - ((x_am) / 9.0).exp() + 1e-12)
210        };
211        let x_ah = candidate.va + 40.0;
212        let ah = if x_ah.abs() < 1e-6 {
213            0.024 * 5.0
214        } else {
215            0.024 * x_ah / (1.0 - (-(x_ah) / 5.0).exp() + 1e-12)
216        };
217        let x_bh = candidate.va + 65.0;
218        let bh = if x_bh.abs() < 1e-6 {
219            0.0091 * 5.0
220        } else {
221            -0.0091 * x_bh / (1.0 - ((x_bh) / 5.0).exp() + 1e-12)
222        };
223        let x_an = candidate.va - 20.0;
224        let an = if x_an.abs() < 1e-6 {
225            0.02 * 9.0
226        } else {
227            0.02 * x_an / (1.0 - (-(x_an) / 9.0).exp() + 1e-12)
228        };
229        let bn = if x_an.abs() < 1e-6 {
230            0.002 * 9.0
231        } else {
232            -0.002 * x_an / (1.0 - ((x_an) / 9.0).exp() + 1e-12)
233        };
234        candidate.m = (candidate.m + (am * (1.0 - candidate.m) - bm * candidate.m) * candidate.dt)
235            .clamp(0.0, 1.0);
236        candidate.h = (candidate.h + (ah * (1.0 - candidate.h) - bh * candidate.h) * candidate.dt)
237            .clamp(0.0, 1.0);
238        candidate.n = (candidate.n + (an * (1.0 - candidate.n) - bn * candidate.n) * candidate.dt)
239            .clamp(0.0, 1.0);
240        let i_na =
241            candidate.g_na * candidate.m.powi(3) * candidate.h * (candidate.va - candidate.e_na);
242        let i_k = candidate.g_k * candidate.n * (candidate.va - candidate.e_k);
243        let i_l_s = candidate.g_l * (candidate.vs - candidate.e_l);
244        candidate.vs = (candidate.vs
245            + (-i_l_s + candidate.kappa * (candidate.va - candidate.vs) + current) / candidate.c_s
246                * candidate.dt)
247            .clamp(-200.0, 200.0);
248        candidate.va = (candidate.va
249            + (-i_na - i_k + candidate.kappa * (candidate.vs - candidate.va)) / candidate.c_a
250                * candidate.dt)
251            .clamp(-200.0, 200.0);
252    }
253
254    /// Fail-closed wrapper for legacy callers: returns 0 on any rejected
255    /// input without mutating state.
256    pub fn step(&mut self, current: f64) -> i32 {
257        self.try_step(current).unwrap_or(0)
258    }
259
260    /// Restore the dynamic state to its initial values, preserving every
261    /// configuration parameter.
262    pub fn reset(&mut self) {
263        self.vs = -65.0;
264        self.va = -65.0;
265        self.m = 0.05;
266        self.h = 0.6;
267        self.n = 0.3;
268    }
269}
270impl Default for MainenSejnowskiNeuron {
271    fn default() -> Self {
272        Self::new()
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn default_matches_constructor_state() {
282        let default = MainenSejnowskiNeuron::default();
283        let constructed = MainenSejnowskiNeuron::new();
284        assert_eq!(default.vs, constructed.vs);
285    }
286
287    #[test]
288    fn removable_rate_singularities_use_finite_limits() {
289        for voltage in [-25.0, -40.0, -65.0, 20.0] {
290            let mut n = MainenSejnowskiNeuron::new();
291            n.va = voltage;
292            let spike = n.step(0.0);
293            assert!(matches!(spike, 0 | 1));
294        }
295    }
296
297    #[test]
298    fn mainen_fires() {
299        let mut n = MainenSejnowskiNeuron::new();
300        let t: i32 = (0..5000).map(|_| n.step(500.0)).sum();
301        assert!(t > 0);
302    }
303
304    // -- MainenSejnowski --
305    #[test]
306    fn mainen_stable_without_input() {
307        // Mainen 1996 model may produce transient spikes at I=0
308        // (confirmed in Python reference). Verify stability only.
309        let mut n = MainenSejnowskiNeuron::new();
310        for _ in 0..500 {
311            n.step(0.0);
312        }
313        assert!(n.vs.is_finite());
314        assert!(n.va.is_finite());
315    }
316    #[test]
317    fn mainen_reset_clears_state() {
318        let mut n = MainenSejnowskiNeuron::new();
319        for _ in 0..100 {
320            n.step(500.0);
321        }
322        n.reset();
323        assert!((n.vs - (-65.0)).abs() < 1e-10);
324        assert!((n.va - (-65.0)).abs() < 1e-10);
325    }
326    #[test]
327    fn mainen_moderate_input_stable() {
328        // Two-compartment model with high conductances — moderate input
329        let mut n = MainenSejnowskiNeuron::new();
330        for _ in 0..200 {
331            n.step(500.0);
332        }
333        // High-conductance 2-compartment may diverge at extremes;
334        // test moderate stability
335        let _ = n.vs; // no panic
336    }
337    #[test]
338    fn mainen_two_compartments_coupled() {
339        let n = MainenSejnowskiNeuron::new();
340        // kappa > 0 means compartments are coupled
341        assert!(n.kappa > 0.0, "coupling should be positive");
342    }
343    #[test]
344    fn mainen_weak_negative_no_crash() {
345        let mut n = MainenSejnowskiNeuron::new();
346        for _ in 0..200 {
347            n.step(-10.0);
348        }
349        // Weak negative is safer for 2-compartment
350        assert!(n.vs.is_finite());
351    }
352    #[test]
353    fn mainen_nan_input_is_rejected_atomically() {
354        let mut n = MainenSejnowskiNeuron::new();
355        let before = n.clone();
356        assert!(n.try_step(f64::NAN).is_err());
357        assert!(n.try_step(f64::INFINITY).is_err());
358        assert_eq!(n.vs, before.vs);
359        assert_eq!(n.va, before.va);
360        assert_eq!(n.m, before.m);
361        assert_eq!(n.h, before.h);
362        assert_eq!(n.n, before.n);
363    }
364
365    #[test]
366    fn mainen_invalid_configuration_is_rejected_atomically() {
367        let mut n = MainenSejnowskiNeuron::new();
368        n.c_s = 0.0;
369        let before = n.clone();
370        assert!(n.try_step(1.0).is_err());
371        assert_eq!(n.vs, before.vs);
372        assert_eq!(n.c_s, before.c_s);
373    }
374
375    #[test]
376    fn mainen_nominal_step_matches_reference_anchor() {
377        let mut n = MainenSejnowskiNeuron::new();
378        assert_eq!(n.try_step(10.0), Ok(0));
379        assert!((n.vs - -32.668_480_035_293_555).abs() < 1.0e-12);
380        assert!((n.va - 200.0).abs() < 1.0e-12);
381        assert!((n.m - 0.600_794_256_701_580_5).abs() < 1.0e-12);
382        assert!((n.h - 0.658_132_236_592_029_5).abs() < 1.0e-12);
383        assert!((n.n - 0.398_198_621_809_121).abs() < 1.0e-12);
384    }
385
386    #[test]
387    fn mainen_rate_limits_are_exact_and_continuous_at_singular_voltages() {
388        assert_eq!(MainenSejnowskiNeuron::linoid(0.0, 9.0), 9.0);
389        assert_eq!(MainenSejnowskiNeuron::linoid(0.0, 5.0), 5.0);
390        for k in [9.0, 5.0] {
391            assert!((MainenSejnowskiNeuron::linoid(1e-9, k) - k).abs() < 1e-8);
392            assert!((MainenSejnowskiNeuron::linoid(-1e-9, k) - k).abs() < 1e-8);
393        }
394        for v_singular in [-25.0, -40.0, -65.0, 20.0] {
395            let mut exact = MainenSejnowskiNeuron::new();
396            exact.va = v_singular;
397            let mut near = MainenSejnowskiNeuron::new();
398            near.va = v_singular + 1e-9;
399            exact.try_step(0.0).expect("finite drive");
400            near.try_step(0.0).expect("finite drive");
401            let delta = (exact.vs - near.vs)
402                .abs()
403                .max((exact.va - near.va).abs())
404                .max((exact.m - near.m).abs())
405                .max((exact.h - near.h).abs())
406                .max((exact.n - near.n).abs());
407            assert!(
408                delta < 1e-6,
409                "public step must be continuous at va={v_singular}, delta={delta}"
410            );
411        }
412    }
413
414    #[test]
415    fn mainen_legacy_sequential_reproduces_the_original_engine_trajectory() {
416        // Anchors captured from the pre-correction engine build
417        // (Gauss-Seidel ordering + |x|<1e-6 limit branches + 1e-12 form).
418        let mut legacy = MainenSejnowskiNeuron::new_legacy_sequential();
419        assert!(legacy.legacy_sequential);
420        assert_eq!(legacy.try_step(10.0), Ok(0));
421        assert!((legacy.vs - -32.668_480_035_293_555).abs() < 1.0e-12);
422        assert!((legacy.va - 200.0).abs() < 1.0e-12);
423        assert!((legacy.m - 0.600_794_256_701_518_1).abs() < 1.0e-12);
424        assert!((legacy.h - 0.658_132_236_591_979_1).abs() < 1.0e-12);
425        assert!((legacy.n - 0.398_198_621_809_030_8).abs() < 1.0e-12);
426
427        let mut long_run = MainenSejnowskiNeuron::new_legacy_sequential();
428        for _ in 0..50 {
429            long_run.step(0.5);
430        }
431        assert!((long_run.vs - -11.459_569_992_989_016).abs() < 1.0e-12);
432        assert!((long_run.h - 0.823_316_701_674_941_8).abs() < 1.0e-12);
433        assert!((long_run.n - 0.890_852_351_095_750_2).abs() < 1.0e-12);
434    }
435}