Skip to main content

sc_neurocore_engine/neurons/
cazelles_map.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 — Cazelles-Courbage-Rabinovich piecewise-linear map
8
9//! Source-faithful scalar map from Cazelles et al. (2001).
10
11/// Four-branch map with the source's Figure-1 defaults.
12#[derive(Clone, Debug)]
13pub struct CazellesMapNeuron {
14    pub x: f64,
15    pub alpha: f64,
16    pub exponent: u8,
17    pub x0: f64,
18    pub x1: f64,
19    pub x2: f64,
20    pub x3: f64,
21    pub x4: f64,
22    pub a1: f64,
23    pub a2: f64,
24    pub a3: f64,
25    pub a4: f64,
26    pub b1: f64,
27    pub b2: f64,
28    pub b3: f64,
29    pub b4: f64,
30}
31
32impl CazellesMapNeuron {
33    pub fn new() -> Self {
34        Self {
35            x: 0.1,
36            alpha: 0.0,
37            exponent: 2,
38            x0: 0.0,
39            x1: 0.4,
40            x2: 0.6,
41            x3: 0.7,
42            x4: 1.0,
43            a1: 0.0,
44            a2: 1.5,
45            a3: -0.9,
46            a4: 1.4,
47            b1: 1.05,
48            b2: -1.25,
49            b3: 1.5,
50            b4: -1.0,
51        }
52    }
53
54    fn parameters_are_valid(&self) -> bool {
55        [
56            self.x, self.alpha, self.x0, self.x1, self.x2, self.x3, self.x4, self.a1, self.a2,
57            self.a3, self.a4, self.b1, self.b2, self.b3, self.b4,
58        ]
59        .iter()
60        .all(|value| value.is_finite())
61            && (0.0..1.0).contains(&self.alpha)
62            && matches!(self.exponent, 1 | 2)
63            && self.x0 < self.x1
64            && self.x1 < self.x2
65            && self.x2 < self.x3
66            && self.x3 < self.x4
67            && (self.x0..=self.x4).contains(&self.x)
68    }
69
70    fn candidate(&self, current: f64) -> Result<f64, &'static str> {
71        let base = if self.x < self.x1 {
72            self.a1 + self.b1 * self.x
73        } else if self.x < self.x2 {
74            self.a2 + self.b2 * self.x
75        } else if self.x < self.x3 {
76            self.a3 + self.b3 * self.x
77        } else {
78            self.a4 + self.b4 * self.x
79        };
80        let power = if self.exponent == 1 {
81            self.x
82        } else {
83            self.x * self.x
84        };
85        let mut candidate = base + self.alpha * power + current;
86        if !candidate.is_finite() {
87            return Err("Cazelles candidate became non-finite");
88        }
89        let tolerance = 8.0 * f64::EPSILON * self.x0.abs().max(self.x4.abs()).max(1.0);
90        if candidate < self.x0 && candidate >= self.x0 - tolerance {
91            candidate = self.x0;
92        } else if candidate > self.x4 && candidate <= self.x4 + tolerance {
93            candidate = self.x4;
94        }
95        if !(self.x0..=self.x4).contains(&candidate) {
96            return Err("Cazelles candidate left its configured domain");
97        }
98        Ok(candidate)
99    }
100
101    /// Checked update; rejected input leaves state unchanged.
102    pub fn try_step(&mut self, current: f64) -> Result<i32, &'static str> {
103        if !self.parameters_are_valid() {
104            return Err("invalid Cazelles runtime state");
105        }
106        if !current.is_finite() {
107            return Err("invalid Cazelles current");
108        }
109        let candidate = self.candidate(current)?;
110        let event = i32::from(self.x >= self.x1 && candidate < self.x1);
111        self.x = candidate;
112        Ok(event)
113    }
114
115    /// Infallible engine-class adapter; the checked batch API reports errors.
116    pub fn step(&mut self, current: f64) -> i32 {
117        self.try_step(current).unwrap_or(0)
118    }
119
120    pub fn simulate(
121        &mut self,
122        n_steps: usize,
123        current: f64,
124    ) -> Result<(Vec<f64>, i64), &'static str> {
125        let mut trace = Vec::with_capacity(n_steps);
126        let mut events = 0_i64;
127        for _ in 0..n_steps {
128            events += i64::from(self.try_step(current)?);
129            trace.push(self.x);
130        }
131        Ok((trace, events))
132    }
133
134    pub fn reset(&mut self) {
135        if self.parameters_are_valid() || self.x0 < self.x4 {
136            self.x = if (self.x0..=self.x4).contains(&0.1) {
137                0.1
138            } else {
139                self.x0
140            };
141        }
142    }
143}
144
145impl Default for CazellesMapNeuron {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn matches_figure_one_orbit() {
157        let mut neuron = CazellesMapNeuron::new();
158        let (trace, events) = neuron.simulate(200, 0.0).unwrap();
159        assert_eq!(events, 2);
160        assert_eq!(trace[0], 0.105_000_000_000_000_01);
161        assert!(trace.iter().all(|x| (0.0..=1.0).contains(x)));
162    }
163
164    #[test]
165    fn breakpoint_convention_is_right_continuous() {
166        let mut neuron = CazellesMapNeuron {
167            x: 0.4,
168            ..CazellesMapNeuron::new()
169        };
170        neuron.try_step(0.0).unwrap();
171        assert_eq!(neuron.x, 1.0);
172    }
173
174    #[test]
175    fn invalid_candidate_is_atomic() {
176        let mut neuron = CazellesMapNeuron::new();
177        let before = neuron.x;
178        assert!(neuron.try_step(2.0).is_err());
179        assert_eq!(neuron.x, before);
180    }
181}