Skip to main content

sc_neurocore_engine/
ping.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 — Rust per-step kernel for the Börgers-Kopell PINGCircuit
8
9//! Step-by-step parity with `PINGCircuit.step` in
10//! `src/sc_neurocore/network/gamma_oscillation.py`.
11//!
12//! Per dt:
13//!   1. Decay AMPA / GABA conductances by `exp(-dt/tau_*)`.
14//!   2. Compute `I = -g_L*(V-E_L) - g_AMPA*(V-E_AMPA)
15//!                  - g_GABA*(V-E_GABA) + I_drive + sqrt(dt)*sigma*xi`.
16//!      (`xi` is supplied externally as a pre-drawn N(0,1) array so
17//!      the Rust path stays bit-deterministic for matching seeds —
18//!      see `tests/test_gamma_oscillation.py::TestPINGCircuitDeterminism`.)
19//!   3. Update `V` Euler-style; cells in refractory are clamped to V_reset.
20//!   4. Detect spikes (`V >= V_th`, not in refractory).
21//!   5. Reset spiked cells to V_reset, start refractory window.
22//!   6. Decrement refractory counters; clamp to 0.
23//!   7. Return per-cell spike booleans + total per-population spike counts
24//!      so the Python wrapper can propagate to conductances.
25//!
26//! This is the kernel for the per-step inner loop. The Python side
27//! still owns:
28//!   - the per-instance RNG (so noise stays seed-deterministic across
29//!     the two backends);
30//!   - the cross-population conductance update step
31//!     (g_ampa_e += w_ee * n_e_spikes etc — handled by the caller).
32
33#[allow(clippy::too_many_arguments)]
34pub fn step_kernel(
35    // Excitatory population.
36    v_e: &mut [f64],
37    g_ampa_e: &mut [f64],
38    g_gaba_e: &mut [f64],
39    refrac_e: &mut [f64],
40    i_drive_e: &[f64],
41    xi_e: &[f64],
42    spikes_e_out: &mut [u8],
43    // Inhibitory population.
44    v_i: &mut [f64],
45    g_ampa_i: &mut [f64],
46    g_gaba_i: &mut [f64],
47    refrac_i: &mut [f64],
48    i_drive_i: &[f64],
49    xi_i: &[f64],
50    spikes_i_out: &mut [u8],
51    // Membrane parameters.
52    e_l: f64,
53    e_ampa: f64,
54    e_gaba: f64,
55    g_l: f64,
56    c_m: f64,
57    v_threshold: f64,
58    v_reset: f64,
59    t_refrac: f64,
60    // Synaptic decay constants.
61    tau_ampa: f64,
62    tau_gaba: f64,
63    // Noise scaling.
64    sigma_e: f64,
65    sigma_i: f64,
66    // Time step.
67    dt: f64,
68) -> (u32, u32) {
69    let decay_ampa = (-dt / tau_ampa).exp();
70    let decay_gaba = (-dt / tau_gaba).exp();
71    let dt_over_cm = dt / c_m;
72    let sqrt_dt = dt.sqrt();
73
74    // Decay conductances first (matches Python ordering).
75    for g in g_ampa_e.iter_mut() {
76        *g *= decay_ampa;
77    }
78    for g in g_ampa_i.iter_mut() {
79        *g *= decay_ampa;
80    }
81    for g in g_gaba_e.iter_mut() {
82        *g *= decay_gaba;
83    }
84    for g in g_gaba_i.iter_mut() {
85        *g *= decay_gaba;
86    }
87
88    // Excitatory population update + spike detect.
89    //
90    // Note: Python adds the Wiener noise increment `sqrt(dt)·σ·ξ`
91    // DIRECTLY to V (not through the `I·dt/C_m` channel). The
92    // Rust path mirrors that exactly so spike outputs are bit-
93    // identical between backends at any (n_e, n_i, σ) point —
94    // verified by `tests/test_gamma_oscillation.py::TestPython
95    // RustParity` and by `bench_gamma_oscillation.py` reproducing
96    // the published 30-80 Hz dominant frequency at all three
97    // benchmark workloads on both backends.
98    let mut n_e: u32 = 0;
99    let n_excit = v_e.len();
100    for k in 0..n_excit {
101        let in_refrac = refrac_e[k] > 0.0;
102        let v_old = v_e[k];
103        let i_leak = -g_l * (v_old - e_l);
104        let i_ampa = -g_ampa_e[k] * (v_old - e_ampa);
105        let i_gaba = -g_gaba_e[k] * (v_old - e_gaba);
106        let i_total = i_leak + i_ampa + i_gaba + i_drive_e[k];
107        let noise = sqrt_dt * sigma_e * xi_e[k];
108        let v_new = if in_refrac {
109            v_reset
110        } else {
111            v_old + i_total * dt_over_cm + noise
112        };
113        let spk = v_new >= v_threshold && !in_refrac;
114        v_e[k] = if spk { v_reset } else { v_new };
115        let new_refrac = if spk { t_refrac } else { refrac_e[k] - dt };
116        refrac_e[k] = if new_refrac > 0.0 { new_refrac } else { 0.0 };
117        spikes_e_out[k] = u8::from(spk);
118        if spk {
119            n_e += 1;
120        }
121    }
122
123    // Inhibitory population update + spike detect.
124    let mut n_i: u32 = 0;
125    let n_inh = v_i.len();
126    for k in 0..n_inh {
127        let in_refrac = refrac_i[k] > 0.0;
128        let v_old = v_i[k];
129        let i_leak = -g_l * (v_old - e_l);
130        let i_ampa = -g_ampa_i[k] * (v_old - e_ampa);
131        let i_gaba = -g_gaba_i[k] * (v_old - e_gaba);
132        let i_total = i_leak + i_ampa + i_gaba + i_drive_i[k];
133        let noise = sqrt_dt * sigma_i * xi_i[k];
134        let v_new = if in_refrac {
135            v_reset
136        } else {
137            v_old + i_total * dt_over_cm + noise
138        };
139        let spk = v_new >= v_threshold && !in_refrac;
140        v_i[k] = if spk { v_reset } else { v_new };
141        let new_refrac = if spk { t_refrac } else { refrac_i[k] - dt };
142        refrac_i[k] = if new_refrac > 0.0 { new_refrac } else { 0.0 };
143        spikes_i_out[k] = u8::from(spk);
144        if spk {
145            n_i += 1;
146        }
147    }
148
149    (n_e, n_i)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    /// Burned-in zero-input case: no spikes, V stays exactly at E_L.
157    #[test]
158    fn test_no_drive_no_spikes() {
159        let n_e = 4;
160        let n_i = 2;
161        let mut v_e = vec![-67.0_f64; n_e];
162        let mut v_i = vec![-67.0_f64; n_i];
163        let mut g_ampa_e = vec![0.0; n_e];
164        let mut g_ampa_i = vec![0.0; n_i];
165        let mut g_gaba_e = vec![0.0; n_e];
166        let mut g_gaba_i = vec![0.0; n_i];
167        let mut refrac_e = vec![0.0; n_e];
168        let mut refrac_i = vec![0.0; n_i];
169        let i_drive_e = vec![0.0; n_e];
170        let i_drive_i = vec![0.0; n_i];
171        let xi_e = vec![0.0; n_e];
172        let xi_i = vec![0.0; n_i];
173        let mut spk_e = vec![0_u8; n_e];
174        let mut spk_i = vec![0_u8; n_i];
175
176        let (ne, ni) = step_kernel(
177            &mut v_e,
178            &mut g_ampa_e,
179            &mut g_gaba_e,
180            &mut refrac_e,
181            &i_drive_e,
182            &xi_e,
183            &mut spk_e,
184            &mut v_i,
185            &mut g_ampa_i,
186            &mut g_gaba_i,
187            &mut refrac_i,
188            &i_drive_i,
189            &xi_i,
190            &mut spk_i,
191            -67.0,
192            0.0,
193            -80.0,
194            0.05,
195            1.0,
196            -52.0,
197            -67.0,
198            2.0,
199            5.0,
200            5.0,
201            0.0,
202            0.0,
203            0.1,
204        );
205        assert_eq!(ne, 0);
206        assert_eq!(ni, 0);
207        for v in &v_e {
208            // No drive + V at E_L → no integration motion at all.
209            assert!((v - (-67.0)).abs() < 1e-9);
210        }
211    }
212
213    /// Strong drive should push V above threshold within a few steps
214    /// and the refractory window must hold the cell at V_reset.
215    #[test]
216    fn test_drive_produces_spikes_and_refractory_holds() {
217        let n_e = 1;
218        let n_i = 1;
219        let mut v_e = vec![-67.0_f64; n_e];
220        let mut v_i = vec![-67.0_f64; n_i];
221        let mut g_ampa_e = vec![0.0; n_e];
222        let mut g_ampa_i = vec![0.0; n_i];
223        let mut g_gaba_e = vec![0.0; n_e];
224        let mut g_gaba_i = vec![0.0; n_i];
225        let mut refrac_e = vec![0.0; n_e];
226        let mut refrac_i = vec![0.0; n_i];
227        let i_drive_e = vec![5.0; n_e]; // µA/cm² — supra-threshold
228        let i_drive_i = vec![0.0; n_i];
229        let xi_e = vec![0.0; n_e];
230        let xi_i = vec![0.0; n_i];
231        let mut total_spikes_e = 0_u32;
232        let mut last_spike_step = -1_i32;
233
234        for step in 0..400_i32 {
235            let mut spk_e = vec![0_u8; n_e];
236            let mut spk_i = vec![0_u8; n_i];
237            let (ne, _ni) = step_kernel(
238                &mut v_e,
239                &mut g_ampa_e,
240                &mut g_gaba_e,
241                &mut refrac_e,
242                &i_drive_e,
243                &xi_e,
244                &mut spk_e,
245                &mut v_i,
246                &mut g_ampa_i,
247                &mut g_gaba_i,
248                &mut refrac_i,
249                &i_drive_i,
250                &xi_i,
251                &mut spk_i,
252                -67.0,
253                0.0,
254                -80.0,
255                0.05,
256                1.0,
257                -52.0,
258                -67.0,
259                2.0,
260                5.0,
261                5.0,
262                0.0,
263                0.0,
264                0.1,
265            );
266            if ne > 0 {
267                if last_spike_step >= 0 {
268                    // dt=0.1, T_refrac=2 → next spike at least
269                    // 20 steps later (refractory holds V at V_reset).
270                    assert!(step - last_spike_step >= 20);
271                }
272                last_spike_step = step;
273            }
274            total_spikes_e += ne;
275        }
276        assert!(total_spikes_e > 0);
277    }
278
279    /// Determinism: identical state + identical xi → identical output.
280    #[test]
281    fn test_deterministic_for_same_inputs() {
282        let n_e = 8;
283        let n_i = 4;
284        let mk = || {
285            (
286                vec![-65.0_f64; n_e],
287                vec![-65.0_f64; n_i],
288                vec![0.5_f64; n_e],
289                vec![0.5_f64; n_i],
290                vec![0.3_f64; n_e],
291                vec![0.3_f64; n_i],
292                vec![0.0_f64; n_e],
293                vec![0.0_f64; n_i],
294            )
295        };
296        let xi_e = vec![0.1_f64; n_e];
297        let xi_i = vec![-0.1_f64; n_i];
298        let i_drive_e = vec![1.0; n_e];
299        let i_drive_i = vec![0.5; n_i];
300
301        let (
302            mut v_e_a,
303            mut v_i_a,
304            mut ga_e_a,
305            mut ga_i_a,
306            mut gg_e_a,
307            mut gg_i_a,
308            mut rf_e_a,
309            mut rf_i_a,
310        ) = mk();
311        let (
312            mut v_e_b,
313            mut v_i_b,
314            mut ga_e_b,
315            mut ga_i_b,
316            mut gg_e_b,
317            mut gg_i_b,
318            mut rf_e_b,
319            mut rf_i_b,
320        ) = mk();
321        let mut spk_e_a = vec![0_u8; n_e];
322        let mut spk_i_a = vec![0_u8; n_i];
323        let mut spk_e_b = vec![0_u8; n_e];
324        let mut spk_i_b = vec![0_u8; n_i];
325
326        let _ = step_kernel(
327            &mut v_e_a,
328            &mut ga_e_a,
329            &mut gg_e_a,
330            &mut rf_e_a,
331            &i_drive_e,
332            &xi_e,
333            &mut spk_e_a,
334            &mut v_i_a,
335            &mut ga_i_a,
336            &mut gg_i_a,
337            &mut rf_i_a,
338            &i_drive_i,
339            &xi_i,
340            &mut spk_i_a,
341            -67.0,
342            0.0,
343            -80.0,
344            0.05,
345            1.0,
346            -52.0,
347            -67.0,
348            2.0,
349            5.0,
350            5.0,
351            0.1,
352            0.05,
353            0.1,
354        );
355        let _ = step_kernel(
356            &mut v_e_b,
357            &mut ga_e_b,
358            &mut gg_e_b,
359            &mut rf_e_b,
360            &i_drive_e,
361            &xi_e,
362            &mut spk_e_b,
363            &mut v_i_b,
364            &mut ga_i_b,
365            &mut gg_i_b,
366            &mut rf_i_b,
367            &i_drive_i,
368            &xi_i,
369            &mut spk_i_b,
370            -67.0,
371            0.0,
372            -80.0,
373            0.05,
374            1.0,
375            -52.0,
376            -67.0,
377            2.0,
378            5.0,
379            5.0,
380            0.1,
381            0.05,
382            0.1,
383        );
384
385        assert_eq!(v_e_a, v_e_b);
386        assert_eq!(v_i_a, v_i_b);
387        assert_eq!(spk_e_a, spk_e_b);
388        assert_eq!(spk_i_a, spk_i_b);
389    }
390}