Skip to main content

sc_neurocore_engine/neurons/rate/
sigmoid_rate.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 — Sigmoid rate neuron model
8
9/// Sigmoid rate neuron — Wilson-Cowan-style single unit.
10#[derive(Clone, Debug)]
11pub struct SigmoidRateNeuron {
12    pub r: f64,
13    pub tau: f64,
14    pub beta: f64,
15    pub theta: f64,
16    pub dt: f64,
17}
18
19impl SigmoidRateNeuron {
20    /// Construct the maintained factory-default rate unit.
21    pub fn new() -> Self {
22        Self::with_parameters(0.0, 10.0, 1.0, 0.0, 0.1)
23            .expect("the factory-default sigmoid-rate contract is valid")
24    }
25
26    /// Construct a fully configurable, validated sigmoid-rate unit.
27    pub fn with_parameters(
28        r: f64,
29        tau: f64,
30        beta: f64,
31        theta: f64,
32        dt: f64,
33    ) -> Result<Self, String> {
34        let neuron = Self {
35            r,
36            tau,
37            beta,
38            theta,
39            dt,
40        };
41        neuron.validate()?;
42        Ok(neuron)
43    }
44
45    /// Validate the complete mutable numeric contract.
46    pub fn validate(&self) -> Result<(), String> {
47        if !self.r.is_finite()
48            || !(0.0..=1.0).contains(&self.r)
49            || !self.tau.is_finite()
50            || self.tau <= 0.0
51            || !self.beta.is_finite()
52            || !self.theta.is_finite()
53            || !self.dt.is_finite()
54            || self.dt <= 0.0
55        {
56            return Err(
57                "sigmoid-rate state and parameters must be finite, with r in [0,1] and positive tau/dt"
58                    .into(),
59            );
60        }
61        Ok(())
62    }
63
64    /// Advance one step, preserving the previous state when validation fails.
65    pub fn try_step(&mut self, current: f64) -> Result<f64, String> {
66        self.validate()?;
67        if !current.is_finite() {
68            return Err("sigmoid-rate current must be finite".into());
69        }
70        let target = stable_sigmoid(self.beta, current, self.theta)?;
71        let decay = (-self.dt / self.tau).exp();
72        let candidate = decay * self.r + (1.0 - decay) * target;
73        if !candidate.is_finite() || !(0.0..=1.0).contains(&candidate) {
74            return Err("sigmoid-rate exact relaxation left the finite unit interval".into());
75        }
76        self.r = candidate;
77        Ok(candidate)
78    }
79
80    /// Advance one step through the legacy non-throwing engine boundary.
81    pub fn step(&mut self, current: f64) -> f64 {
82        self.try_step(current).unwrap_or(self.r)
83    }
84
85    /// Restore the dynamic rate state without changing configured parameters.
86    pub fn reset(&mut self) {
87        self.r = 0.0;
88    }
89}
90
91fn stable_sigmoid(beta: f64, current: f64, theta: f64) -> Result<f64, String> {
92    let argument = beta * (current - theta);
93    if argument.is_infinite() {
94        return Ok(if argument.is_sign_positive() {
95            1.0
96        } else {
97            0.0
98        });
99    }
100    if !argument.is_finite() {
101        return Err("sigmoid-rate transfer argument must be finite or saturating".into());
102    }
103    if argument >= 0.0 {
104        Ok(1.0 / (1.0 + (-argument).exp()))
105    } else {
106        let exponential = argument.exp();
107        Ok(exponential / (1.0 + exponential))
108    }
109}
110impl Default for SigmoidRateNeuron {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn sigmoid_rate() {
122        let mut n = SigmoidRateNeuron::new();
123        for _ in 0..100 {
124            n.step(5.0);
125        }
126        assert!(n.r > 0.5);
127    }
128
129    #[test]
130    fn sigmoid_rate_matches_python_exact_relaxation_golden() {
131        let mut neuron = SigmoidRateNeuron::with_parameters(0.25, 10.0, 2.0, 1.0, 0.5).unwrap();
132        let expected = [
133            0.2857007338135623,
134            0.3196603222932904,
135            0.3519636820991432,
136            0.38269158845670403,
137            0.41192087713731845,
138            0.43972463658754457,
139        ];
140        for target in expected {
141            let rate = neuron.try_step(3.0).unwrap();
142            assert!((rate - target).abs() <= 2.0e-15, "{rate} != {target}");
143        }
144    }
145
146    #[test]
147    fn sigmoid_rate_exact_relaxation_is_bounded_for_large_timestep() {
148        let mut neuron = SigmoidRateNeuron::with_parameters(1.0, 0.1, 1.0, 0.0, 5.0).unwrap();
149        let rate = neuron.try_step(-100.0).unwrap();
150        assert!((rate - 1.9287498479639178e-22).abs() <= 1.0e-36);
151        assert!((0.0..=1.0).contains(&rate));
152    }
153
154    #[test]
155    fn sigmoid_rate_rejects_invalid_contract_without_mutation() {
156        let invalid_contracts = [
157            (-0.1, 10.0, 1.0, 0.0, 0.1),
158            (1.1, 10.0, 1.0, 0.0, 0.1),
159            (0.0, 0.0, 1.0, 0.0, 0.1),
160            (0.0, 10.0, f64::NAN, 0.0, 0.1),
161            (0.0, 10.0, 1.0, f64::INFINITY, 0.1),
162            (0.0, 10.0, 1.0, 0.0, -0.1),
163        ];
164        for (r, tau, beta, theta, dt) in invalid_contracts {
165            assert!(SigmoidRateNeuron::with_parameters(r, tau, beta, theta, dt).is_err());
166        }
167
168        let mut neuron = SigmoidRateNeuron::with_parameters(0.25, 10.0, 2.0, 1.0, 0.5).unwrap();
169        let before = neuron.r;
170        assert!(neuron.try_step(f64::NAN).is_err());
171        assert_eq!(neuron.r, before);
172        neuron.tau = 0.0;
173        assert!(neuron.try_step(3.0).is_err());
174        assert_eq!(neuron.r, before);
175    }
176
177    #[test]
178    fn sigmoid_rate_saturates_extreme_finite_drive() {
179        let mut high = SigmoidRateNeuron::with_parameters(0.0, 10.0, 1.0e308, 0.0, 0.1).unwrap();
180        let mut low = high.clone();
181        assert!(high.try_step(1.0e308).unwrap() > 0.0);
182        assert_eq!(low.try_step(-1.0e308).unwrap(), 0.0);
183    }
184
185    #[test]
186    fn sigmoid_rate_reset_preserves_configuration() {
187        let mut neuron = SigmoidRateNeuron::with_parameters(0.25, 7.0, 2.5, -0.4, 0.2).unwrap();
188        neuron.try_step(3.0).unwrap();
189        neuron.reset();
190        assert_eq!(neuron.r, 0.0);
191        assert_eq!(
192            (neuron.tau, neuron.beta, neuron.theta, neuron.dt),
193            (7.0, 2.5, -0.4, 0.2)
194        );
195    }
196
197    #[test]
198    fn sigmoid_rate_legacy_step_fails_closed() {
199        let mut neuron = SigmoidRateNeuron::with_parameters(0.25, 10.0, 2.0, 1.0, 0.5).unwrap();
200        assert_eq!(neuron.step(f64::NAN), 0.25);
201        assert_eq!(neuron.r, 0.25);
202    }
203}