Skip to main content

sc_neurocore_engine/neuron/
homeostatic_lif.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 — Homeostatic LIF neuron
8
9/// Homeostatic LIF neuron with adaptive threshold.
10///
11/// Threshold adapts via EMA of spike rate toward a target setpoint.
12/// Turrigiano, Cold Spring Harb Perspect Biol 4:a005736, 2012.
13#[derive(Clone, Debug)]
14pub struct HomeostaticLif {
15    pub v: f64,
16    pub v_threshold: f64,
17    pub v_rest: f64,
18    pub v_reset: f64,
19    pub rate_trace: f64,
20    pub target_rate: f64,
21    pub adaptation_rate: f64,
22    pub trace_decay: f64,
23    initial_threshold: f64,
24}
25
26impl HomeostaticLif {
27    pub fn new(target_rate: f64, adaptation_rate: f64, trace_decay: f64) -> Self {
28        Self {
29            v: 0.0,
30            v_threshold: 1.0,
31            v_rest: 0.0,
32            v_reset: 0.0,
33            rate_trace: 0.0,
34            target_rate,
35            adaptation_rate,
36            trace_decay,
37            initial_threshold: 1.0,
38        }
39    }
40
41    pub fn with_defaults() -> Self {
42        Self::new(0.1, 0.01, 0.95)
43    }
44
45    /// LIF step with threshold adaptation. Returns 1 on spike.
46    pub fn step(&mut self, current: f64) -> i32 {
47        let tau = 20.0;
48        self.v += (-(self.v - self.v_rest) + current) / tau;
49
50        let spike = if self.v >= self.v_threshold {
51            self.v = self.v_reset;
52            1
53        } else {
54            0
55        };
56
57        self.rate_trace =
58            self.rate_trace * self.trace_decay + spike as f64 * (1.0 - self.trace_decay);
59        let error = self.rate_trace - self.target_rate;
60        self.v_threshold += self.adaptation_rate * error;
61        self.v_threshold = self.v_threshold.clamp(0.1, self.initial_threshold * 10.0);
62
63        spike
64    }
65
66    pub fn reset(&mut self) {
67        self.v = self.v_rest;
68        self.rate_trace = 0.0;
69        self.v_threshold = self.initial_threshold;
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::HomeostaticLif;
76
77    #[test]
78    fn strong_input_produces_spikes() {
79        let mut neuron = HomeostaticLif::with_defaults();
80        let spikes: i32 = (0..200).map(|_| neuron.step(25.0)).sum();
81        assert!(spikes > 0, "must fire with strong input");
82    }
83
84    #[test]
85    fn repeated_spiking_adapts_threshold() {
86        let mut neuron = HomeostaticLif::with_defaults();
87        let initial = neuron.v_threshold;
88        for _ in 0..500 {
89            neuron.step(25.0);
90        }
91        assert!(
92            (neuron.v_threshold - initial).abs() > 1e-6,
93            "threshold must adapt"
94        );
95    }
96
97    #[test]
98    fn zero_input_remains_silent() {
99        let mut neuron = HomeostaticLif::with_defaults();
100        let spikes: i32 = (0..100).map(|_| neuron.step(0.0)).sum();
101        assert_eq!(spikes, 0);
102    }
103
104    #[test]
105    fn adaptive_threshold_remains_bounded() {
106        let mut neuron = HomeostaticLif::with_defaults();
107        for _ in 0..10_000 {
108            neuron.step(50.0);
109        }
110        assert!((0.1..=10.0).contains(&neuron.v_threshold));
111    }
112}