#![allow(clippy::module_name_repetitions)]
#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)]
pub const MAX_SPIKE_HISTORY: usize = 64;
const _: () = assert!(MAX_SPIKE_HISTORY.is_power_of_two());
#[derive(Clone)]
struct SpikeRing {
buf: [u32; MAX_SPIKE_HISTORY],
head: usize,
len: usize,
}
impl SpikeRing {
const fn new() -> Self {
Self {
buf: [0; MAX_SPIKE_HISTORY],
head: 0,
len: 0,
}
}
fn push(&mut self, t: u32) {
debug_assert!(self.head < MAX_SPIKE_HISTORY);
if self.len < MAX_SPIKE_HISTORY {
self.buf[(self.head + self.len) & (MAX_SPIKE_HISTORY - 1)] = t;
self.len += 1;
} else {
self.buf[self.head & (MAX_SPIKE_HISTORY - 1)] = t;
self.head = (self.head + 1) % MAX_SPIKE_HISTORY;
}
}
fn clear(&mut self) {
self.head = 0;
self.len = 0;
}
#[cfg(test)]
fn len(&self) -> usize {
self.len
}
#[cfg(test)]
fn is_empty(&self) -> bool {
self.len == 0
}
fn get(&self, i: usize) -> u32 {
assert!(
i < self.len,
"SpikeRing index {i} out of range {}",
self.len
);
self.buf[(self.head + i) % MAX_SPIKE_HISTORY]
}
fn iter(&self) -> impl Iterator<Item = u32> + '_ {
(0..self.len).map(move |i| self.get(i))
}
}
impl core::fmt::Debug for SpikeRing {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_list().entries(self.iter()).finish()
}
}
pub const MEMBRANE_MV_MIN: i16 = -100;
pub const MEMBRANE_MV_MAX: i16 = 50;
#[inline]
#[must_use]
pub fn dt_over_tau(dt_us: u32, tau_membrane_us: u32) -> i64 {
if tau_membrane_us == 0 {
return 0;
}
if dt_us <= u32::MAX / 1000 {
i64::from(dt_us * 1000 / tau_membrane_us)
} else {
dt_over_tau_wide(dt_us, tau_membrane_us)
}
}
#[cold]
#[inline(never)]
fn dt_over_tau_wide(dt_us: u32, tau_membrane_us: u32) -> i64 {
let raw = (u64::from(dt_us) * 1000) / u64::from(tau_membrane_us);
raw as i64
}
#[inline]
fn div_1000(x: i64) -> i64 {
if let Ok(y) = i32::try_from(x) {
i64::from(y / 1000)
} else {
div_1000_wide(x)
}
}
#[cold]
#[inline(never)]
fn div_1000_wide(x: i64) -> i64 {
x / 1000
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VoltageResolution {
#[default]
Millivolt,
CentiMillivolt,
}
impl VoltageResolution {
#[must_use]
pub const fn scale(self) -> i32 {
match self {
Self::Millivolt => 1,
Self::CentiMillivolt => 100,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NeuronType {
#[default]
Excitatory,
Inhibitory,
}
#[derive(Debug, Clone)]
pub struct LIFNeuron {
pub id: u16,
pub neuron_type: NeuronType,
pub membrane_potential: i16,
pub resting_potential: i16,
pub threshold: i16,
pub reset_potential: i16,
pub voltage_resolution: VoltageResolution,
pub tau_membrane_us: u32,
pub tau_refractory_us: u32,
pub refractory_time_us: u32,
pub last_update_time_us: u32,
pub last_spike_time_us: u32,
pub synaptic_current_ua: i16,
pub capacitance_pf: u16,
pub resistance_mohm: u16,
pub noise_amplitude_ua: u8,
pub adaptation_current_ua: i16,
spike_history: SpikeRing,
}
impl LIFNeuron {
#[must_use]
pub fn new(id: u16) -> Self {
Self::new_with_type(id, NeuronType::Excitatory)
}
#[must_use]
fn new_with_type(id: u16, neuron_type: NeuronType) -> Self {
Self::new_with_type_resolution(id, neuron_type, VoltageResolution::Millivolt)
}
#[must_use]
pub fn new_with_type_resolution(
id: u16,
neuron_type: NeuronType,
resolution: VoltageResolution,
) -> Self {
let (threshold_mv, tau_membrane_us, capacitance_pf) = match neuron_type {
NeuronType::Excitatory => (-55, 20_000, 100), NeuronType::Inhibitory => (-50, 10_000, 80), };
let s = resolution.scale();
Self {
id,
neuron_type,
membrane_potential: (-70 * s) as i16,
resting_potential: (-70 * s) as i16,
threshold: (threshold_mv * s) as i16,
reset_potential: (-80 * s) as i16,
voltage_resolution: resolution,
tau_membrane_us,
tau_refractory_us: 2_000,
refractory_time_us: 0,
last_update_time_us: 0,
last_spike_time_us: 0,
synaptic_current_ua: 0,
capacitance_pf,
resistance_mohm: 100,
noise_amplitude_ua: 5,
adaptation_current_ua: 0,
spike_history: SpikeRing::new(),
}
}
#[inline]
pub fn integrate_and_fire(
&mut self,
input_current_ua: i16,
dt_us: u32,
current_time_us: u32,
) -> bool {
self.last_update_time_us = current_time_us;
if self.refractory_time_us > 0 {
self.refractory_time_us = self.refractory_time_us.saturating_sub(dt_us);
return false;
}
let noise = self.generate_noise(current_time_us);
let total_current = input_current_ua
.saturating_add(self.synaptic_current_ua)
.saturating_add(noise)
.saturating_sub(self.adaptation_current_ua);
let s = i64::from(self.voltage_resolution.scale());
let dt_over_tau = dt_over_tau(dt_us, self.tau_membrane_us);
let leak_term = i64::from(self.resting_potential) - i64::from(self.membrane_potential);
let current_term = div_1000(i64::from(total_current) * i64::from(self.resistance_mohm) * s);
let delta_v = div_1000(dt_over_tau.saturating_mul(leak_term + current_term));
let new_v = i64::from(self.membrane_potential)
.saturating_add(delta_v)
.clamp(
i64::from(MEMBRANE_MV_MIN) * s,
i64::from(MEMBRANE_MV_MAX) * s,
);
self.membrane_potential = new_v as i16;
if self.membrane_potential >= self.threshold {
self.spike(current_time_us);
true
} else {
false
}
}
fn spike(&mut self, current_time_us: u32) {
self.membrane_potential = self.reset_potential;
self.refractory_time_us = self.tau_refractory_us;
self.last_spike_time_us = current_time_us;
self.spike_history.push(current_time_us);
self.adaptation_current_ua = self.adaptation_current_ua.saturating_add(2);
}
pub fn add_synaptic_current(&mut self, current_ua: i16) {
self.synaptic_current_ua = self.synaptic_current_ua.saturating_add(current_ua);
}
pub fn clear_synaptic_current(&mut self) {
self.synaptic_current_ua = 0;
}
pub fn decay_adaptation_current(&mut self) {
if self.adaptation_current_ua > 0 {
self.adaptation_current_ua = self.adaptation_current_ua.saturating_sub(1);
}
}
pub fn reset(&mut self) {
self.membrane_potential = self.resting_potential;
self.refractory_time_us = 0;
self.last_update_time_us = 0;
self.last_spike_time_us = 0;
self.synaptic_current_ua = 0;
self.adaptation_current_ua = 0;
self.spike_history.clear();
}
fn generate_noise(&self, current_time_us: u32) -> i16 {
if self.noise_amplitude_ua == 0 {
return 0;
}
let mut lfsr = u32::from(self.id) ^ current_time_us;
for _ in 0..4 {
lfsr = (lfsr >> 1) ^ (if lfsr & 1 != 0 { 0xB400_u32 } else { 0 });
}
let raw = (lfsr & 0xFF) as i16 - 128; (raw * i16::from(self.noise_amplitude_ua)) / 128
}
}
impl Default for LIFNeuron {
fn default() -> Self {
Self::new(0)
}
}
#[cfg(test)]
impl LIFNeuron {
fn set_voltage_resolution(&mut self, resolution: VoltageResolution) {
if resolution == self.voltage_resolution {
return;
}
let new_s = resolution.scale();
let old_s = self.voltage_resolution.scale();
let rescale = |v: i16| -> i16 { ((i32::from(v) * new_s) / old_s) as i16 };
self.membrane_potential = rescale(self.membrane_potential);
self.resting_potential = rescale(self.resting_potential);
self.threshold = rescale(self.threshold);
self.reset_potential = rescale(self.reset_potential);
self.voltage_resolution = resolution;
}
fn is_refractory(&self) -> bool {
self.refractory_time_us > 0
}
fn firing_rate_mhz(&self, window_us: u32) -> u32 {
if self.spike_history.is_empty() || window_us == 0 {
return 0;
}
let window_start = self.last_update_time_us.saturating_sub(window_us);
let count = self
.spike_history
.iter()
.filter(|&t| t >= window_start)
.count() as u32;
count
.saturating_mul(1_000_000_000)
.checked_div(window_us)
.unwrap_or(0)
}
fn isi_stats_us(&self) -> Option<(u32, u32)> {
if self.spike_history.len() < 2 {
return None;
}
let n = self.spike_history.len();
let mut intervals_sum: u128 = 0;
let mut intervals_sqsum: u128 = 0;
let mut count: u128 = 0;
for i in 1..n {
let prev = self.spike_history.get(i - 1);
let curr = self.spike_history.get(i);
if curr >= prev {
let d = u128::from(curr - prev);
intervals_sum += d;
intervals_sqsum += d * d;
count += 1;
}
}
if count == 0 {
return None;
}
let mean = intervals_sum / count;
let mean_sq = mean * mean;
let sq_mean = intervals_sqsum / count;
let variance = u64::try_from(sq_mean.saturating_sub(mean_sq))
.expect("variance of u32 intervals is below 2^64");
let std = isqrt_u64(variance);
Some((mean as u32, std as u32))
}
fn spike_count(&self) -> usize {
self.spike_history.len()
}
}
#[cfg(test)]
fn isqrt_u64(n: u64) -> u64 {
if n == 0 {
return 0;
}
let mut x = n;
let mut y = x.div_ceil(2);
while y < x {
x = y;
y = u64::midpoint(x, n / x);
}
x
}
#[cfg(test)]
mod tests {
#![allow(clippy::shadow_unrelated)]
use super::*;
use proptest::prelude::*;
#[test]
fn new_excitatory_neuron_has_default_params() {
let n = LIFNeuron::new(1);
assert_eq!(n.id, 1);
assert_eq!(n.neuron_type, NeuronType::Excitatory);
assert_eq!(n.membrane_potential, -70);
assert_eq!(n.threshold, -55);
assert_eq!(n.tau_membrane_us, 20_000);
}
#[test]
fn inhibitory_neuron_has_different_params() {
let n = LIFNeuron::new_with_type(2, NeuronType::Inhibitory);
assert_eq!(n.threshold, -50);
assert_eq!(n.tau_membrane_us, 10_000);
}
#[test]
fn neuron_fields_directly_configurable() {
let mut n = LIFNeuron::new_with_type(3, NeuronType::Inhibitory);
n.threshold = -45;
n.tau_membrane_us = 15_000;
assert_eq!(n.id, 3);
assert_eq!(n.neuron_type, NeuronType::Inhibitory);
assert_eq!(n.threshold, -45);
assert_eq!(n.tau_membrane_us, 15_000);
}
#[test]
fn resolution_switch_rescales_stored_potentials() {
let mut n = LIFNeuron::new_with_type_resolution(
10,
NeuronType::Excitatory,
VoltageResolution::CentiMillivolt,
);
n.membrane_potential = -6_000;
n.set_voltage_resolution(VoltageResolution::Millivolt);
assert_eq!(n.membrane_potential, -60);
assert_eq!(n.threshold, -55);
assert_eq!(n.reset_potential, -80);
}
#[test]
fn positive_current_raises_membrane_potential() {
let mut n = LIFNeuron::new(4);
let initial = n.membrane_potential;
let spiked = n.integrate_and_fire(100, 10_000, 10_000);
assert!(
n.membrane_potential > initial,
"membrane should rise with sustained positive input (was {}, now {})",
initial,
n.membrane_potential
);
assert!(!spiked, "single 10 ms step at 100 μA should not spike");
}
fn quiet_neuron(id: u16, r: VoltageResolution) -> LIFNeuron {
let mut n = LIFNeuron::new_with_type_resolution(id, NeuronType::Excitatory, r);
n.noise_amplitude_ua = 0;
n
}
#[test]
fn millivolt_trace_is_pinned_to_the_historical_arithmetic() {
let mut n = quiet_neuron(11, VoltageResolution::Millivolt);
let expected = [-67, -65, -63, -61, -59, -57];
for &want in &expected {
let spiked = n.integrate_and_fire(600, 1000, 0);
assert!(!spiked);
assert_eq!(n.membrane_potential, want);
}
let spiked = n.integrate_and_fire(600, 1000, 0);
assert!(spiked, "7th 600 μA step crosses −55");
assert_eq!(n.membrane_potential, -80, "spike reset");
}
#[test]
fn the_dead_zone_12ua_pair() {
let mut mv = quiet_neuron(12, VoltageResolution::Millivolt);
mv.integrate_and_fire(12, 1000, 0);
assert_eq!(mv.membrane_potential, -70, "mV grid: 12 μA ⇒ ΔV = 0");
let mut cmv = quiet_neuron(13, VoltageResolution::CentiMillivolt);
cmv.integrate_and_fire(12, 1000, 0);
assert_eq!(
cmv.membrane_potential,
-7_000 + 6,
"centi grid: 12 μA ⇒ 6 cV"
);
}
#[test]
fn above_threshold_current_blind_on_mv_spikes_on_centi() {
let mut mv = quiet_neuron(14, VoltageResolution::Millivolt);
let mut mv_spikes = 0;
for t in 0..100 {
if mv.integrate_and_fire(160, 1000, t) {
mv_spikes += 1;
}
}
assert_eq!(mv_spikes, 0, "mV grid is blind to 160 μA from rest");
assert_eq!(mv.membrane_potential, -70);
let mut cmv = quiet_neuron(15, VoltageResolution::CentiMillivolt);
let mut cmv_spikes = 0;
for t in 0..100 {
if cmv.integrate_and_fire(160, 1000, t) {
cmv_spikes += 1;
}
}
assert!(cmv_spikes >= 1, "centi grid fires on 160 μA (V_ss −54 mV)");
}
#[test]
fn centi_mode_constants_and_bounds() {
let n = quiet_neuron(16, VoltageResolution::CentiMillivolt);
assert_eq!(n.membrane_potential, -7_000);
assert_eq!(n.resting_potential, -7_000);
assert_eq!(n.threshold, -5_500);
assert_eq!(n.reset_potential, -8_000);
let mut m = quiet_neuron(17, VoltageResolution::CentiMillivolt);
m.membrane_potential = -9_999;
m.integrate_and_fire(-30_000, 1000, 0); assert!(m.membrane_potential >= -10_000, "clamped at scaled floor");
}
#[test]
fn rectification_at_the_sticking_point() {
let mut n = quiet_neuron(18, VoltageResolution::Millivolt);
for t in 0..50 {
n.integrate_and_fire(300, 1000, t);
}
assert_eq!(n.membrane_potential, -59, "sticks 4 mV under threshold");
n.integrate_and_fire(300, 1000, 100);
assert_eq!(n.membrane_potential, -59, "stays stuck under drive alone");
n.add_synaptic_current(12);
n.integrate_and_fire(300, 1000, 101);
assert_eq!(n.membrane_potential, -58, "positive pulse ratchets +1 mV");
n.add_synaptic_current(-12);
n.integrate_and_fire(300, 1000, 102);
assert_eq!(n.membrane_potential, -58, "negative pulse absorbed");
}
#[test]
fn large_current_triggers_spike_and_refractory() {
let mut n = LIFNeuron::new(5);
n.threshold = -65;
let mut spiked = false;
let mut t = 0_u32;
for _ in 0..10 {
if n.integrate_and_fire(200, 10_000, t) {
spiked = true;
break;
}
t = t.saturating_add(10_000);
}
assert!(spiked, "200 μA over 10 ms steps should eventually spike");
assert_eq!(n.membrane_potential, n.reset_potential);
assert!(n.is_refractory());
}
#[test]
fn refractory_blocks_repeated_spikes() {
let mut n = LIFNeuron::new(6);
n.membrane_potential = n.threshold + 1;
let spiked = n.integrate_and_fire(0, 1000, 1000);
assert!(spiked);
let again = n.integrate_and_fire(1000, 1000, 2000);
assert!(!again, "must not spike during refractory");
}
#[test]
fn synaptic_current_accumulates_then_clears() {
let mut n = LIFNeuron::new(7);
n.add_synaptic_current(30);
n.add_synaptic_current(20);
assert_eq!(n.synaptic_current_ua, 50);
n.clear_synaptic_current();
assert_eq!(n.synaptic_current_ua, 0);
}
#[test]
fn reset_restores_initial_state() {
let mut n = LIFNeuron::new(8);
n.membrane_potential = -50;
n.synaptic_current_ua = 100;
n.adaptation_current_ua = 10;
n.spike(5000);
n.spike(6000);
assert_eq!(n.spike_count(), 2);
n.reset();
assert_eq!(n.membrane_potential, n.resting_potential);
assert_eq!(n.synaptic_current_ua, 0);
assert_eq!(n.adaptation_current_ua, 0);
assert!(!n.is_refractory());
assert_eq!(n.spike_count(), 0);
}
#[test]
fn firing_rate_uses_last_update_time_not_last_spike() {
let mut n = LIFNeuron::new(9);
n.membrane_potential = n.threshold + 1;
let _ = n.integrate_and_fire(0, 1000, 1000);
for step in 2..=10_000 {
let _ = n.integrate_and_fire(0, 1000, step * 1000);
}
let rate_mhz = n.firing_rate_mhz(1_000_000);
assert_eq!(
rate_mhz, 0,
"no spikes in last 1 s — rate must be 0 (v0.1 would have returned > 0)"
);
}
#[test]
fn isi_stats_none_with_fewer_than_two_spikes() {
let n = LIFNeuron::new(10);
assert!(n.isi_stats_us().is_none());
}
#[test]
fn isi_stats_computed_with_two_spikes() {
let mut n = LIFNeuron::new(11);
n.spike(1_000);
n.spike(2_000); let (mean, std) = n.isi_stats_us().expect("two spikes → Some");
assert_eq!(mean, 1000);
assert_eq!(std, 0);
}
#[test]
fn noise_is_zero_when_amplitude_is_zero() {
let mut n = LIFNeuron::new(12);
n.noise_amplitude_ua = 0;
for t in 0..1000_u32 {
assert_eq!(n.generate_noise(t), 0);
}
}
#[test]
fn noise_varies_with_time_for_same_id() {
let n = LIFNeuron::new(13);
let samples: [i16; 8] = [
n.generate_noise(100),
n.generate_noise(101),
n.generate_noise(102),
n.generate_noise(103),
n.generate_noise(104),
n.generate_noise(105),
n.generate_noise(106),
n.generate_noise(107),
];
let first = samples[0];
let all_same = samples.iter().all(|&s| s == first);
assert!(
!all_same,
"noise must vary with time — got identical values across 8 samples (all = {first})"
);
}
#[test]
fn the_saturating_multiply_lands_where_an_unbounded_integer_would() {
let rows: [(&str, i16, i16, i16, u16, bool, i16); 4] = [
("mV, positive", -32768, 32767, 32767, 65535, false, 50),
("mV, negative", 32767, -32768, -32768, 65535, false, -100),
("centi, positive", -32768, 32767, 32767, 65535, true, 5000),
(
"centi, negative",
32767,
-32768,
-32768,
65535,
true,
-10000,
),
];
for (name, mp, rp, input, resistance, centi, expected) in rows {
let resolution = if centi {
VoltageResolution::CentiMillivolt
} else {
VoltageResolution::Millivolt
};
let mut n = LIFNeuron::new(0);
n.voltage_resolution = resolution;
n.membrane_potential = mp;
n.resting_potential = rp;
n.resistance_mohm = resistance;
n.threshold = i16::MAX;
n.tau_membrane_us = 1;
n.noise_amplitude_ua = 0;
n.synaptic_current_ua = 0;
n.adaptation_current_ua = 0;
n.refractory_time_us = 0;
let _ = n.integrate_and_fire(input, u32::MAX, 0);
let s = i128::from(resolution.scale());
let dtot = (i128::from(u32::MAX) * 1000) / i128::from(n.tau_membrane_us);
let leak = i128::from(rp) - i128::from(mp);
let current_term = (i128::from(input) * i128::from(resistance) * s) / 1000;
let product = dtot * (leak + current_term);
assert!(
product > i128::from(i64::MAX) || product < i128::from(i64::MIN),
"{name}: this row must actually saturate i64, |product| = {}",
product.abs()
);
let unbounded = (i128::from(mp) + product / 1000).clamp(
i128::from(MEMBRANE_MV_MIN) * s,
i128::from(MEMBRANE_MV_MAX) * s,
);
assert_eq!(i128::from(n.membrane_potential), unbounded, "{name}");
assert_eq!(n.membrane_potential, expected, "{name}");
}
}
#[test]
fn div_1000_is_exact_on_both_sides_of_the_guard() {
let (min, max) = (i64::from(i32::MIN), i64::from(i32::MAX));
let rows: [(i64, i64); 12] = [
(min - 1, -2_147_483),
(min, -2_147_483),
(max, 2_147_483),
(max + 1, 2_147_483),
(i64::MIN, -9_223_372_036_854_775),
(i64::MAX, 9_223_372_036_854_775),
(999, 0),
(-999, 0),
(1000, 1),
(-1000, -1),
(1001, 1),
(-1001, -1),
];
for (x, expected) in rows {
assert_eq!(x / 1000, expected, "the table: {x} / 1000");
assert_eq!(div_1000(x), expected, "div_1000({x})");
}
}
proptest! {
#[test]
fn prop_div_1000_equals_i64_division(
x in prop_oneof![any::<i32>().prop_map(i64::from), any::<i64>()],
) {
prop_assert_eq!(div_1000(x), x / 1000, "x = {}", x);
}
}
proptest! {
#[test]
fn prop_integrate_and_fire_is_exact_over_the_whole_domain(
mp in any::<i16>(),
rp in any::<i16>(),
input in any::<i16>(),
resistance in any::<u16>(),
tau_us in 1u32..=u32::MAX,
dt_us in 0u32..=u32::MAX,
centi in any::<bool>(),
) {
let resolution = if centi {
VoltageResolution::CentiMillivolt
} else {
VoltageResolution::Millivolt
};
let mut n = LIFNeuron::new(0);
n.voltage_resolution = resolution;
n.membrane_potential = mp;
n.resting_potential = rp;
n.tau_membrane_us = tau_us;
n.resistance_mohm = resistance;
n.threshold = i16::MAX; n.noise_amplitude_ua = 0;
n.synaptic_current_ua = 0;
n.adaptation_current_ua = 0;
n.refractory_time_us = 0;
let _ = n.integrate_and_fire(input, dt_us, 0);
let s = i128::from(resolution.scale());
let dtot = (i128::from(dt_us) * 1000) / i128::from(tau_us);
let leak = i128::from(rp) - i128::from(mp);
let current_term = (i128::from(input) * i128::from(resistance) * s) / 1000;
let delta = (dtot * (leak + current_term)) / 1000;
let expected = (i128::from(mp) + delta)
.clamp(i128::from(MEMBRANE_MV_MIN) * s, i128::from(MEMBRANE_MV_MAX) * s);
prop_assert_eq!(
i128::from(n.membrane_potential), expected,
"membrane {} != exact {} (mp={} rp={} input={} resistance={} dt={} tau={} centi={})",
n.membrane_potential, expected, mp, rp, input, resistance, dt_us, tau_us, centi
);
let bound = i128::from(MEMBRANE_MV_MAX) * s;
prop_assert!(
i128::from(n.membrane_potential).abs() <= bound.max(i128::from(MEMBRANE_MV_MIN).abs() * s),
"membrane {} left the voltage grid", n.membrane_potential
);
}
#[test]
fn prop_reset_clears_all_state(id in 0u16..=1000) {
let mut n = LIFNeuron::new(id);
n.add_synaptic_current(50);
n.adaptation_current_ua = 20;
n.spike(5000);
n.spike(6000);
n.reset();
prop_assert_eq!(n.membrane_potential, n.resting_potential);
prop_assert_eq!(n.synaptic_current_ua, 0);
prop_assert_eq!(n.adaptation_current_ua, 0);
prop_assert_eq!(n.refractory_time_us, 0);
prop_assert_eq!(n.spike_count(), 0);
}
#[test]
fn prop_membrane_potential_stays_bounded(
id in 0u16..=100,
input in -1000i16..=1000,
dt in 1u32..=10_000,
start_t in 0u32..=1_000_000,
) {
let mut n = LIFNeuron::new(id);
let mut t = start_t;
for _ in 0..100 {
let _ = n.integrate_and_fire(input, dt, t);
t = t.saturating_add(dt);
}
prop_assert!(n.membrane_potential >= MEMBRANE_MV_MIN);
prop_assert!(n.membrane_potential <= MEMBRANE_MV_MAX);
}
#[test]
fn prop_firing_rate_respects_refractory_bound(
id in 0u16..=50,
tau_ref in 500u32..=10_000,
) {
let mut n = LIFNeuron::new(id);
n.tau_refractory_us = tau_ref;
n.threshold = -100; let mut t = 0_u32;
for _ in 0..100 {
let _ = n.integrate_and_fire(1000, 1000, t);
t += 1000;
}
let max_rate_mhz = 1_000_000_000_u32 / tau_ref;
let ceiling = max_rate_mhz + max_rate_mhz / 20; let actual = n.firing_rate_mhz(t);
prop_assert!(
actual <= ceiling,
"rate {} mHz exceeds refractory ceiling {} mHz",
actual,
ceiling
);
}
#[test]
fn prop_history_never_exceeds_capacity(id in 0u16..=10) {
let mut n = LIFNeuron::new(id);
n.threshold = -100;
for i in 0..1000_u32 {
let _ = n.integrate_and_fire(1000, 1000, i * 1000);
}
prop_assert!(n.spike_count() <= MAX_SPIKE_HISTORY);
}
}
#[test]
fn adaptation_decay_is_exact_minus_one_with_floor_zero() {
let mut n = quiet_neuron(21, VoltageResolution::Millivolt);
n.adaptation_current_ua = 3;
n.decay_adaptation_current();
assert_eq!(n.adaptation_current_ua, 2);
n.decay_adaptation_current();
assert_eq!(n.adaptation_current_ua, 1);
n.decay_adaptation_current();
assert_eq!(n.adaptation_current_ua, 0);
n.decay_adaptation_current();
assert_eq!(n.adaptation_current_ua, 0, "floors at zero, never negative");
}
#[test]
fn spike_adds_exactly_two_adaptation_quanta() {
let mut n = quiet_neuron(22, VoltageResolution::Millivolt);
n.threshold = -100; let spiked = n.integrate_and_fire(1000, 1000, 0);
assert!(spiked);
assert_eq!(n.adaptation_current_ua, 2, "+2 per spike");
}
#[test]
fn a_time_constant_longer_than_i32_no_longer_inverts_the_step() {
assert_eq!(dt_over_tau(1000, u32::MAX), 0, "tau >> dt is a zero step");
let mut n = quiet_neuron(24, VoltageResolution::Millivolt);
n.tau_membrane_us = u32::MAX;
n.threshold = i16::MAX; let before = n.membrane_potential;
let _ = n.integrate_and_fire(1000, 1000, 0);
assert_eq!(
n.membrane_potential, before,
"a 4295 s time constant must not move the membrane in a 1 ms step"
);
}
#[test]
fn a_time_step_past_the_i32_product_no_longer_overflows() {
assert_eq!(dt_over_tau(2_147_484, u32::MAX), 0);
assert_eq!(dt_over_tau(i32::MAX as u32, u32::MAX), 499);
assert_eq!(
dt_over_tau(u32::MAX, 1),
4_294_967_295_000,
"exact, not clamped"
);
assert_eq!(
dt_over_tau(1000, 20_000),
50,
"the physical default is untouched"
);
let mut n = quiet_neuron(25, VoltageResolution::Millivolt);
n.membrane_potential = MEMBRANE_MV_MIN;
n.threshold = i16::MAX;
let _ = n.integrate_and_fire(0, 2_147_484, 0);
assert_eq!(n.membrane_potential, MEMBRANE_MV_MAX);
}
#[test]
fn dt_over_tau_is_exact_on_both_sides_of_the_guard() {
let m = u32::MAX;
let rows: [(u32, u32, i64); 11] = [
(4_294_967, 1, 4_294_967_000),
(4_294_968, 1, 4_294_968_000),
(4_294_967, 7, 613_566_714),
(4_294_968, 7, 613_566_857),
(4_294_967, m, 0),
(4_294_968, m, 1),
(m, 1, 4_294_967_295_000),
(m, m, 1_000),
(1_000, 20_000, 50),
(0, 20_000, 0),
(1_000, 0, 0),
];
for (dt, tau, expected) in rows {
let formula = if tau == 0 {
0
} else {
(u64::from(dt) * 1000 / u64::from(tau)) as i64
};
assert_eq!(formula, expected, "the table: ({dt}, {tau})");
assert_eq!(dt_over_tau(dt, tau), expected, "dt_over_tau({dt}, {tau})");
}
}
proptest! {
#[test]
fn prop_dt_over_tau_equals_the_u64_formula(
dt in prop_oneof![0u32..=4_294_967, any::<u32>()],
tau in any::<u32>(),
) {
let formula = if tau == 0 {
0
} else {
(u64::from(dt) * 1000 / u64::from(tau)) as i64
};
prop_assert_eq!(dt_over_tau(dt, tau), formula, "dt = {} tau = {}", dt, tau);
}
}
#[test]
fn the_neuron_is_exact_where_the_batch_clamps() {
const BATCH_BOUND: i64 = 1884;
let mut n = quiet_neuron(26, VoltageResolution::Millivolt);
n.membrane_potential = -100;
n.resting_potential = -70;
n.tau_membrane_us = 20_000;
n.threshold = i16::MAX;
let exact = dt_over_tau(40_000, 20_000);
assert_eq!(exact, 2000, "dt/tau = 2, exactly");
assert!(
exact > BATCH_BOUND,
"the witness must be above the batch's bound"
);
let _ = n.integrate_and_fire(0, 40_000, 0);
assert_eq!(n.membrane_potential, -40, "the neuron takes the exact step");
let clamped = -100 + i16::try_from(BATCH_BOUND * 30 / 1000).expect("fits i16");
assert_eq!(clamped, -44, "what the batch's bound would have given");
assert_ne!(
n.membrane_potential, clamped,
"the divergence above dt/tau = 1.884 is real and documented, not a bug"
);
}
#[test]
fn leak_convergence_large_dt_lands_on_rest_exactly() {
let mut n = quiet_neuron(23, VoltageResolution::Millivolt);
n.membrane_potential = -90;
let _ = n.integrate_and_fire(0, 20_000, 20_000);
assert_eq!(n.membrane_potential, n.resting_potential, "from below");
n.membrane_potential = -20;
let _ = n.integrate_and_fire(0, 20_000, 40_000);
assert_eq!(n.membrane_potential, n.resting_potential, "from above");
let _ = n.integrate_and_fire(0, 20_000, 60_000);
assert_eq!(n.membrane_potential, n.resting_potential, "stays at rest");
}
struct HeaplessOracle {
history: heapless::Vec<u32, MAX_SPIKE_HISTORY>,
}
impl HeaplessOracle {
fn new() -> Self {
Self {
history: heapless::Vec::new(),
}
}
fn push(&mut self, t: u32) {
if self.history.is_full() {
self.history.remove(0);
}
let _ = self.history.push(t);
}
fn firing_rate_mhz(&self, last_update_time_us: u32, window_us: u32) -> u32 {
if self.history.is_empty() || window_us == 0 {
return 0;
}
let window_start = last_update_time_us.saturating_sub(window_us);
let count = self.history.iter().filter(|&&t| t >= window_start).count() as u32;
count
.saturating_mul(1_000_000_000)
.checked_div(window_us)
.unwrap_or(0)
}
fn isi_stats_us(&self) -> Option<(u32, u32)> {
if self.history.len() < 2 {
return None;
}
let n = self.history.len();
let mut intervals_sum: u128 = 0;
let mut intervals_sqsum: u128 = 0;
let mut count: u128 = 0;
for i in 1..n {
let prev = self.history[i - 1];
let curr = self.history[i];
if curr >= prev {
let d = u128::from(curr - prev);
intervals_sum += d;
intervals_sqsum += d * d;
count += 1;
}
}
if count == 0 {
return None;
}
let mean = intervals_sum / count;
let mean_sq = mean * mean;
let sq_mean = intervals_sqsum / count;
let variance = u64::try_from(sq_mean.saturating_sub(mean_sq))
.expect("variance of u32 intervals is below 2^64");
let std = isqrt_u64(variance);
Some((mean as u32, std as u32))
}
}
proptest! {
#[test]
fn prop_ring_matches_the_heapless_oracle(
times in proptest::collection::vec(any::<u32>(), 0..300),
last_update_time_us in any::<u32>(),
window_us in any::<u32>(),
) {
let mut n = LIFNeuron::new(1);
let mut oracle = HeaplessOracle::new();
for &t in × {
n.spike(t);
oracle.push(t);
}
let ring: std::vec::Vec<u32> = n.spike_history.iter().collect();
let vec: std::vec::Vec<u32> = oracle.history.iter().copied().collect();
prop_assert_eq!(ring, vec, "iteration order and contents");
n.last_update_time_us = last_update_time_us;
prop_assert_eq!(n.spike_count(), oracle.history.len());
prop_assert_eq!(
n.firing_rate_mhz(window_us),
oracle.firing_rate_mhz(last_update_time_us, window_us)
);
prop_assert_eq!(n.isi_stats_us(), oracle.isi_stats_us());
}
#[test]
fn prop_ring_keeps_the_newest_in_order(
times in proptest::collection::vec(any::<u32>(), 0..300),
) {
let mut ring = SpikeRing::new();
for &t in × {
ring.push(t);
}
let live = times.len().min(MAX_SPIKE_HISTORY);
prop_assert_eq!(ring.len(), live);
prop_assert_eq!(ring.is_empty(), live == 0);
let expected = ×[times.len() - live..];
let got: std::vec::Vec<u32> = ring.iter().collect();
prop_assert_eq!(got.as_slice(), expected);
for (i, &t) in expected.iter().enumerate() {
prop_assert_eq!(ring.get(i), t);
}
ring.clear();
prop_assert!(ring.is_empty());
prop_assert_eq!(ring.iter().count(), 0);
ring.push(7);
prop_assert_eq!(ring.len(), 1);
prop_assert_eq!(ring.get(0), 7);
}
}
#[test]
fn isi_stats_survive_three_maximal_intervals() {
let times = [0, u32::MAX, 0, u32::MAX, 0, u32::MAX];
let mut n = LIFNeuron::new(1);
let mut oracle = HeaplessOracle::new();
for &t in × {
n.spike(t);
oracle.push(t);
}
assert_eq!(n.isi_stats_us(), Some((u32::MAX, 0)));
assert_eq!(oracle.isi_stats_us(), Some((u32::MAX, 0)));
}
#[test]
fn ring_debug_shows_live_entries_only() {
let mut ring = SpikeRing::new();
assert_eq!(std::format!("{ring:?}"), "[]");
ring.push(1);
ring.push(2);
assert_eq!(std::format!("{ring:?}"), "[1, 2]");
let mut ring = SpikeRing::new();
for t in 0..70_u32 {
ring.push(t);
}
let shown = std::format!("{ring:?}");
assert!(shown.starts_with("[6, 7, 8,"), "{shown}");
assert!(shown.ends_with(" 69]"), "{shown}");
assert_eq!(ring.iter().count(), MAX_SPIKE_HISTORY);
}
#[test]
#[should_panic(expected = "SpikeRing index 2 out of range 2")]
fn ring_get_past_len_panics_like_a_slice() {
let mut ring = SpikeRing::new();
ring.push(10);
ring.push(20);
let _ = ring.get(2);
}
}