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