Skip to main content

feagi_structures/genomic/
modulator.rs

1// Copyright 2025 Neuraville Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Built-in modulator kinds and the burst signal model.
5//!
6//! Modulator *types* are fixed by FEAGI. Users create instances of these types.
7//! The signal formulas here are the single definition used by the genome
8//! validator and the burst engine.
9
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13/// Built-in modulator type. The set is not user-editable.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub enum ModulatorKind {
16    /// Neuron firing threshold. `threshold * (1 + signal)`.
17    FiringThreshold,
18    /// Neuron leak coefficient, clamped to `[0, 1]`.
19    Leak,
20    /// Probabilistic firing excitability, clamped to `[0, 1]`.
21    FiringProbability,
22    /// PSP of the subscribed mapping's synapses. `psp * (1 + signal)`.
23    TransmissionGain,
24    /// Adds `signal` into the mapping's R-STDP reward `R(t)`.
25    Reward,
26    /// STDP / R-STDP eta of the mapping. `eta * (1 + signal)`.
27    LearningRate,
28    /// Open/closed switch on the subscribed mapping. Transmission is 0 unless a
29    /// gate instance in the group has an active driver; then the factor is the signal.
30    Gate,
31    /// Memory formation switch on a subscribed memory area. Factor is
32    /// `clamp(1 + signal, 0, 1)`; at 0 the area forms no new memory neurons.
33    MemoryFormation,
34    /// Memory freeze switch on a subscribed memory area. While the combined signal is
35    /// above 0 the area is a full snapshot: no creation, ageing, lifespan growth,
36    /// long-term conversion or reward consolidation. Existing memory neurons still fire.
37    /// Magnitude must be above 0.
38    MemoryFreeze,
39}
40
41impl ModulatorKind {
42    /// Stable genome and API identifier.
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Self::FiringThreshold => "neuro.firing_threshold",
46            Self::Leak => "neuro.leak",
47            Self::FiringProbability => "neuro.firing_probability",
48            Self::TransmissionGain => "synaptic.transmission_gain",
49            Self::Reward => "synaptic.reward",
50            Self::LearningRate => "synaptic.learning_rate",
51            Self::Gate => "synaptic.gate",
52            Self::MemoryFormation => "memory.formation",
53            Self::MemoryFreeze => "memory.freeze",
54        }
55    }
56
57    /// Parse a genome or API type string.
58    pub fn parse(value: &str) -> Result<Self, String> {
59        match value {
60            "neuro.firing_threshold" => Ok(Self::FiringThreshold),
61            "neuro.leak" => Ok(Self::Leak),
62            "neuro.firing_probability" => Ok(Self::FiringProbability),
63            "synaptic.transmission_gain" => Ok(Self::TransmissionGain),
64            "synaptic.reward" => Ok(Self::Reward),
65            "synaptic.learning_rate" => Ok(Self::LearningRate),
66            "synaptic.gate" => Ok(Self::Gate),
67            "memory.formation" => Ok(Self::MemoryFormation),
68            "memory.freeze" => Ok(Self::MemoryFreeze),
69            other => Err(format!("unknown modulator type '{other}'")),
70        }
71    }
72
73    /// Every built-in type, in stable order.
74    pub fn all() -> &'static [Self] {
75        &[
76            Self::FiringThreshold,
77            Self::Leak,
78            Self::FiringProbability,
79            Self::TransmissionGain,
80            Self::Reward,
81            Self::LearningRate,
82            Self::Gate,
83            Self::MemoryFormation,
84            Self::MemoryFreeze,
85        ]
86    }
87
88    /// Neuromodulators subscribe on cortical areas.
89    pub fn is_neuromodulator(self) -> bool {
90        matches!(
91            self,
92            Self::FiringThreshold | Self::Leak | Self::FiringProbability
93        )
94    }
95
96    /// Kinds that subscribe on cortical areas: `neuro.*`, `memory.formation`, `memory.freeze`.
97    pub fn is_area_level(self) -> bool {
98        self.is_neuromodulator() || matches!(self, Self::MemoryFormation | Self::MemoryFreeze)
99    }
100
101    /// Synaptic modulators subscribe on mapping rules.
102    pub fn is_synaptic(self) -> bool {
103        !self.is_area_level()
104    }
105
106    /// Reward combines by addition. Every other kind combines by multiplication.
107    pub fn combines_by_addition(self) -> bool {
108        matches!(self, Self::Reward)
109    }
110}
111
112impl fmt::Display for ModulatorKind {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118/// Why an instance definition was rejected.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ModulatorValidationError(pub String);
121
122impl fmt::Display for ModulatorValidationError {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.write_str(&self.0)
125    }
126}
127
128/// Check instance fields that do not depend on the rest of the genome.
129///
130/// `magnitude_percent` must be finite and `>= -100` so every multiplicative
131/// factor stays non-negative. `effect_duration_bursts` must be at least 1.
132/// Graded instances require `full_scale_potential > 0`.
133pub fn validate_instance_fields(
134    magnitude_percent: f32,
135    effect_duration_bursts: u16,
136    graded: bool,
137    full_scale_potential: Option<f32>,
138) -> Result<(), ModulatorValidationError> {
139    if !magnitude_percent.is_finite() || magnitude_percent < -100.0 {
140        return Err(ModulatorValidationError(
141            "magnitude_percent must be finite and >= -100".to_string(),
142        ));
143    }
144    if effect_duration_bursts < 1 {
145        return Err(ModulatorValidationError(
146            "effect_duration_bursts must be >= 1".to_string(),
147        ));
148    }
149    if graded {
150        match full_scale_potential {
151            Some(scale) if scale.is_finite() && scale > 0.0 => {}
152            _ => {
153                return Err(ModulatorValidationError(
154                    "graded modulators require full_scale_potential > 0".to_string(),
155                ));
156            }
157        }
158    }
159    Ok(())
160}
161
162/// `spike_train` with `consecutive_fire_limit == 0` would never end.
163pub fn validate_spike_train(
164    enabled: bool,
165    consecutive_fire_limit: u16,
166) -> Result<(), ModulatorValidationError> {
167    if enabled && consecutive_fire_limit < 1 {
168        return Err(ModulatorValidationError(
169            "spike_train requires consecutive_fire_limit >= 1".to_string(),
170        ));
171    }
172    Ok(())
173}
174
175/// Signal of one instance at one burst.
176///
177/// `fired` includes spike-train firings. When `graded` is false, a firing has
178/// strength 1. When graded, strength is `min(membrane_potential / full_scale, 1)`.
179/// A silent driver contributes 0.
180pub fn modulator_signal(
181    magnitude_percent: f32,
182    fired: bool,
183    graded: bool,
184    membrane_potential: f32,
185    full_scale_potential: f32,
186) -> f32 {
187    if !fired {
188        return 0.0;
189    }
190    let strength = if graded {
191        (membrane_potential / full_scale_potential).min(1.0)
192    } else {
193        1.0
194    };
195    (magnitude_percent / 100.0) * strength
196}
197
198/// Product of `(1 + signal)` across instances that share a parameter.
199pub fn multiplicative_factor(signals: &[f32]) -> f32 {
200    signals
201        .iter()
202        .fold(1.0_f32, |acc, signal| acc * (1.0 + signal))
203}
204
205/// Transmission factor of a group's gate instances: the largest signal (open if
206/// any gate is open), never below 0. A silent driver has signal 0, so the gate is closed.
207pub fn gate_factor(signals: &[f32]) -> f32 {
208    signals.iter().copied().fold(0.0_f32, f32::max)
209}
210
211/// Memory formation factor: product of `(1 + signal)` clamped to `[0, 1]`.
212/// 0 means the area forms no new memory neurons.
213pub fn memory_formation_factor(signals: &[f32]) -> f32 {
214    multiplicative_factor(signals).clamp(0.0, 1.0)
215}
216
217/// Memory freeze: the area is frozen while the summed signal is above 0.
218pub fn memory_freeze_active(signals: &[f32]) -> bool {
219    signals.iter().sum::<f32>() > 0.0
220}
221
222/// Sum of reward signals. Reward does not use the multiplicative factor.
223pub fn summed_reward(signals: &[f32]) -> f32 {
224    signals.iter().sum()
225}
226
227/// Scale a stored baseline and clamp to the parameter's valid range.
228///
229/// Leak and excitability are clamped to `[0, 1]`. Threshold, transmission
230/// gain, and learning rate stay non-negative. Reward is not a scaled parameter.
231pub fn scale_baseline(kind: ModulatorKind, baseline: f32, factor: f32) -> f32 {
232    let value = baseline * factor;
233    match kind {
234        ModulatorKind::Leak | ModulatorKind::FiringProbability => value.clamp(0.0, 1.0),
235        ModulatorKind::FiringThreshold
236        | ModulatorKind::TransmissionGain
237        | ModulatorKind::LearningRate
238        | ModulatorKind::Gate
239        | ModulatorKind::MemoryFormation
240        | ModulatorKind::MemoryFreeze => value.max(0.0),
241        ModulatorKind::Reward => value,
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn silent_driver_contributes_nothing() {
251        assert_eq!(modulator_signal(30.0, false, false, 10.0, 10.0), 0.0);
252    }
253
254    #[test]
255    fn ungraded_firing_uses_full_magnitude() {
256        assert!((modulator_signal(30.0, true, false, 1.0, 10.0) - 0.3).abs() < 1e-6);
257        assert!((modulator_signal(-80.0, true, false, 1.0, 10.0) + 0.8).abs() < 1e-6);
258    }
259
260    #[test]
261    fn graded_firing_scales_with_membrane_potential_and_caps_at_one() {
262        let half = modulator_signal(30.0, true, true, 5.0, 10.0);
263        assert!((half - 0.15).abs() < 1e-6);
264        let capped = modulator_signal(30.0, true, true, 40.0, 10.0);
265        assert!((capped - 0.3).abs() < 1e-6);
266    }
267
268    #[test]
269    fn factors_multiply_and_reward_adds() {
270        let factor = multiplicative_factor(&[0.3, -0.5]);
271        assert!((factor - (1.3 * 0.5)).abs() < 1e-6);
272        assert!((summed_reward(&[0.3, -0.5]) + 0.2).abs() < 1e-6);
273    }
274
275    #[test]
276    fn leak_and_excitability_clamp_to_unit_interval() {
277        assert!((scale_baseline(ModulatorKind::Leak, 0.8, 2.0) - 1.0).abs() < 1e-6);
278        assert!((scale_baseline(ModulatorKind::FiringProbability, 0.2, 0.0) - 0.0).abs() < 1e-6);
279        assert!(scale_baseline(ModulatorKind::FiringThreshold, 2.0, 3.0) > 0.0);
280    }
281
282    #[test]
283    fn magnitude_below_minus_100_is_rejected() {
284        assert!(validate_instance_fields(-100.1, 1, false, None).is_err());
285        assert!(validate_instance_fields(-100.0, 1, false, None).is_ok());
286    }
287
288    #[test]
289    fn graded_requires_positive_full_scale() {
290        assert!(validate_instance_fields(10.0, 1, true, None).is_err());
291        assert!(validate_instance_fields(10.0, 1, true, Some(0.0)).is_err());
292        assert!(validate_instance_fields(10.0, 1, true, Some(4.0)).is_ok());
293    }
294
295    #[test]
296    fn spike_train_rejects_unlimited_consecutive_fire() {
297        assert!(validate_spike_train(true, 0).is_err());
298        assert!(validate_spike_train(true, 1).is_ok());
299        assert!(validate_spike_train(false, 0).is_ok());
300    }
301
302    #[test]
303    fn memory_freeze_is_area_level_and_active_above_zero() {
304        assert_eq!(
305            ModulatorKind::parse("memory.freeze").unwrap(),
306            ModulatorKind::MemoryFreeze
307        );
308        assert!(ModulatorKind::MemoryFreeze.is_area_level());
309        assert!(!ModulatorKind::MemoryFreeze.is_synaptic());
310        assert!(!memory_freeze_active(&[]));
311        assert!(!memory_freeze_active(&[0.0]));
312        assert!(memory_freeze_active(&[0.5]));
313    }
314
315    #[test]
316    fn type_strings_round_trip() {
317        for kind in ModulatorKind::all() {
318            assert_eq!(ModulatorKind::parse(kind.as_str()).unwrap(), *kind);
319        }
320    }
321
322    #[test]
323    fn gate_kind_is_synaptic_and_parses() {
324        assert_eq!(
325            ModulatorKind::parse("synaptic.gate").unwrap(),
326            ModulatorKind::Gate
327        );
328        assert_eq!(ModulatorKind::Gate.as_str(), "synaptic.gate");
329        assert!(ModulatorKind::Gate.is_synaptic());
330        assert!(!ModulatorKind::Gate.combines_by_addition());
331    }
332
333    #[test]
334    fn gate_factor_is_closed_when_silent_and_open_if_any_open() {
335        assert_eq!(gate_factor(&[0.0]), 0.0);
336        assert_eq!(gate_factor(&[0.0, 1.0]), 1.0);
337        assert!((gate_factor(&[0.5, 0.25]) - 0.5).abs() < 1e-6);
338    }
339
340    #[test]
341    fn memory_formation_is_area_level_and_clamped() {
342        assert_eq!(
343            ModulatorKind::parse("memory.formation").unwrap(),
344            ModulatorKind::MemoryFormation
345        );
346        assert!(ModulatorKind::MemoryFormation.is_area_level());
347        assert!(!ModulatorKind::MemoryFormation.is_synaptic());
348        assert!(!ModulatorKind::MemoryFormation.is_neuromodulator());
349        assert_eq!(memory_formation_factor(&[-1.0]), 0.0);
350        assert_eq!(memory_formation_factor(&[0.0]), 1.0);
351        assert_eq!(memory_formation_factor(&[0.5]), 1.0);
352        assert!((memory_formation_factor(&[-0.25]) - 0.75).abs() < 1e-6);
353        assert_eq!(memory_formation_factor(&[]), 1.0);
354    }
355}